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>
svc-net-tor rendered its own guard script, its own unit and its own handler, and hooked the rules into DOCKER-USER with an insert, because a rule appended to a chain docker owns lands behind the RETURN docker puts there. The script flushed its chain and refilled it rule by rule, so the boundary was gone for the duration of the refill.
A role that needs packet filter rules now writes templates/nftables.conf.j2 declaring its own table and includes svc-net-firewall, which renders it to /etc/nftables.d/<role>.conf, rejects it with nft -c, loads it, reads the table back and refuses one that carries no chain. Deployed on its own the role installs nftables and the ruleset that loads every fragment at boot. That ruleset is a distribution decision, so its path follows os_family: RedHat reads /etc/sysconfig/nftables.conf, the others /etc/nftables.conf. The unit is only enabled, never started, because on Debian its ExecStop flushes the whole ruleset, docker's tables included.
One table per role removes the shared path: a fragment replaces its own table and touches no other, and docker never touches either. nft -f on an existing table merges, so each fragment creates, deletes and redeclares its table in one transaction. The tests load every fragment twice into a throwaway network namespace and require the listing to match, because a merging fragment loads twice without error and shows up only as a duplicated rule.
The tor guard now applies in both deploy modes. It was pinned to swarm while torrc binds TransPort and DNSPort on the bridge address in compose too, so on a single-host install those ports answered every on-link neighbour. The guard drops rather than admits, so nothing short-circuits another table, and ip saddr carries an implicit ipv4 dependency, which leaves the ipv6 exposure exactly where the iptables rules left it. svc-net-tor reads its own chain back and refuses a guard that drops nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sending to a .onion needs two things of the node: a resolver that answers the suffix, and a route from the container to Tor's transparent port. Neither existed. Compose pointed Mailu at unbound, which knows nothing about .onion, and in swarm Tor's DNSPort and TransPort listen inside the container where the host's dnsmasq and nat rules cannot reach them, so the forwarding rules that were already there pointed at a closed port on every swarm node.
Compose resolves through unbound, which now forwards the onion zone to Tor's DNSPort on the bridge it already owns. The forward alone would have answered SERVFAIL: the signed root proves that onion does not exist, so with auto-trust-anchor-file set every answer below it validates as bogus, and Tor's DNSPort can supply no DS. The zone is therefore declared insecure. Swarm resolves through the node dnsmasq instead, which every container already uses because container_dns puts the bridge resolver into daemon.json wherever svc-net-tor is deployed, and which now reaches Tor because its ports are published host-mode next to the SOCKS port that was published there already.
The transport rule changed target rather than mechanism. REDIRECT rewrites the destination to the primary address of the interface the packet arrived on, which is a different project bridge for every role, so Tor would have had to listen on all of them. A DNAT naming the bridge keeps one listener, and in swarm the same address reaches the published port through the proxy docker runs there.
That leaves the exposure. SocksPort has SocksPolicy; DNSPort and TransPort have no equivalent, and a swarm-published port carries no host address to bind to, so in swarm the packet filter is their only boundary. Compose needs none, because there Tor binds loopback and the bridge and nothing else. The swarm guard admits this deployment's own address space and drops the rest, and it is installed as a unit ordered after docker rather than as ansible-applied rules: nothing here persists netfilter state, so docker recreating its chains at boot would have left Tor back on the wildcard with the guard gone. It rebuilds its chain instead of appending to it, because the appending version could only ever be loosened. It hooks INPUT and DOCKER-USER both, since host-local traffic reaches a published port through docker's proxy and external traffic through FORWARD.
The deploy now proves all of it back and fails when a rule is missing, the router no longer runs on swarm nodes that carry no Tor, and the CLI test probes the forwarded ports over SOCKS instead of assuming a HiddenServicePort line means a reachable service.
Measured so far: Tor accepts the non-loopback binds and reports circuit-established, and the chain, its order and the DOCKER-USER hooks read back correctly from a deployed host. The swarm path is not yet exercised.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run 34421944328 Nextcloud#13 lost the dashboard's Matomo tracker. The
dns53 sampler on mgr-01 shows no :53 socket from 01:39:52 to 01:48:44
while the dnsmasq PID stayed the same, six seconds after a short-lived
container's veth on docker0. In that window the manager could not
resolve its own onion vhosts, so sys-front-inj-matomo's site checks for
web-svc-logout (01:45:35) and web-app-dashboard (01:47:18) never reached
OpenResty, the injection was disabled as "API not ready", and the
Playwright check found no matomo.js. The other four swarm managers of
the run that carried the same listener kept it throughout.
Under bind-dynamic, dnsmasq 2.91 re-enumerates interfaces on every
netlink address event and then garbage-collects every listener it did
not re-find (network.c enumerate_interfaces), also when the enumeration
failed; release_listener has no other caller. 5aa7506e36 reproduced this
on the lab nodes and 11c98825aa already moved the bridge-less case to a
static bind. The bridge case now binds statically as well.
This supersedes two earlier rationales, both measured on the local stack:
- bb0e098a9f kept bind-dynamic because dnsmasq starts at boot before
docker0 exists. A unit drop-in (files/dnsmasq-restart.conf,
Restart=on-failure, RestartSec=5) now retries the start: with a missing
listen-address the start exits 2, and dnsmasq came up 4s after the
address appeared.
- 4739b2de99 called bind-dynamic load-bearing because docker0 can
disappear across a docker restart. Only a start without the address is
fatal; a bound socket survived the address being removed and re-added
and the interface being recreated with a new ifindex, answering on the
same socket inode each time.
The docker0 fact is re-gathered before torrc and the dnsmasq drop-in are
rendered. The play-start fact predates default-address-pools moving the
bridge (285ba1e76c), and a static bind on a stale address would make
dnsmasq exit. container_dns reads only the pool, so daemon.json is not
affected by the refresh.
The host resolv.conf moves from an inline copy content to
templates/resolv.conf.j2; both it and the drop-in render byte-identical
to what the local stack already runs.
A veth churn of 13.8k cycles did not reproduce the loss locally, under
either bind mode.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Line-length reflow in three test modules and a stray blank line in zammad variants. In router.yml the formatter also moved the nsswitch lineinfile regex from single to double quotes with the backslashes doubled to match; single-quoted YAML already passes a backslash through literally, so the string reaching lineinfile is byte-identical and the rule still changes only NOTFOUND.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The role sets up the .onion split-DNS and points /etc/resolv.conf at dnsmasq,
but resolv.conf only feeds the glibc `dns` module, and the image ships
hosts: files myhostname resolve [!UNAVAIL=return] dns
`[!UNAVAIL=return]` returns on every status except UNAVAIL, so once
systemd-resolved refuses a name, glibc never reaches dnsmasq. systemd-resolved
refuses .onion as a special-use domain, which makes the role's own split-DNS
unreachable to every NSS consumer on the host.
Measured in run 33785198875 job 100756597086: dnsmasq kept the same pid from
20:30 to the capture, logged nothing, and answered `dig @127.0.0.1` correctly
at 22:05:05, while getaddrinfo failed for 28 minutes. Fourteen .onion domains
returned EAI_NONAME in 4-5ms each, an authoritative answer rather than a
timeout, and the one clearnet domain in the same probe was fine. msmtp failed
26 times on the same suffix, a second consumer of the same path. 11c98825aa
addressed the trigger, a bind-dynamic listener flap that recovered in 30
seconds; this addresses why the failure outlived it by 28 minutes.
Only NOTFOUND changes. `resolve` keeps its place ahead of `dns`, so
systemd-resolved still answers everything it knows and SUCCESS still returns;
TRYAGAIN keeps returning, so a timeout does not buy a second query on a host
whose upstream is already struggling. Stripping the qualifier outright, which
was the first shape of this fix, would have changed three statuses to buy one.
The two halves now sit in one block under a single TOR_DNSMASQ_OWNS_LISTENER
guard: pointing resolv.conf at dnsmasq and making NSS able to use it are one
concern, and splitting them is what left the role half finished.
Residual worth naming: if systemd-resolved answers UNAVAIL rather than
NOTFOUND for .onion, the old qualifier already fell through and the cause lies
elsewhere. The 4-5ms replies argue for NOTFOUND but do not settle it. The
in-zone probe cannot settle it either: 56b8f1716b taught it to find the zone
in the drop-in directory, but it still asks through `getent`, i.e. through the
very layer in question. A direct per-nameserver dig would decide it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
54aeba7e0 pinned /etc/resolv.conf at 127.0.0.1 so onion names route
through the role's dnsmasq. That pin has no fallback, so every way
dnsmasq can stop answering takes the whole host's name resolution with
it.
It did. In run 32284541284 job 96178507650 the rescue bundle shows
sockets-udp.txt with zero :53 entries, `dig @127.0.0.1` returning
"connection refused", the dnsmasq process alive (state S, PPID 1) and
systemctl reporting `loaded active running` - 1201 resolution failures
from 21:58:17 onward, never recovering, while CoreDNS on the configured
upstream answered correctly the whole time.
The second entry is the same address dnsmasq already forwards to
(networks.internet.dns, the upstream 54aeba7e0 introduced), so this adds
no new destination: with dnsmasq up, glibc never reaches it; with dnsmasq
down, the host reaches the upstream directly instead of nothing. It is
the instinct plugins/lookup/container_dns.py already documents for
containers - "the clearnet resolver stays as a second entry so that
losing dnsmasq costs onion names rather than all name resolution" - and
daemon.json has carried both entries all along. Only the host was left
without one.
Unconditional, not guarded: router.yml:2-7 asserts networks.internet.dns
is non-empty before the file does anything else, so a guard would be dead
code.
Deliberately not fixing this by dropping bind-dynamic from
templates/dnsmasq-tor-onion.conf.j2. Without the directive dnsmasq binds
0.0.0.0:53, which on Ubuntu collides with systemd-resolved on
127.0.0.53:53 - reproduced on dnsmasq 2.93: "failed to create listening
socket for port ...: Address already in use", exit 2. bind-interfaces is
worse: TOR_CONTAINER_DNS_HOST is a deploy-time docker0 fact, and dnsmasq
starts at boot before docker0 exists. A fallback resolver covers the
listener loss, the bind collision and the boot-order miss at once.
Known trade: while dnsmasq is down, .onion lookups fall through to the
clearnet resolver and return NXDOMAIN instead of timing out. RFC 7686
says .onion must not reach DNS, and the role's own test cannot see this
because it resolves onions through --socks5-hostname.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Outside compose TOR_CONTAINER_DNS_HOST was empty, so the drop-in rendered no
listen-address and lookup('container_dns') handed containers the clearnet
resolver alone. A swarm stack host ran the egress router without a listener its
containers could reach, and .onion stayed unresolvable for every one of them
while compose resolved it fine. Ownership already governs the listener, the
upstream policy and the resolv.conf pin; the deploy mode only ever stood in for
it. With the mode clause gone both modes render the drop-in compose has been
running in production, and compose keeps its exact behaviour: where matched == 0
the old and the new expression are the same boolean.
In CI run 31927586060 the swarm node dnsmasq logged "ignoring query from
non-local network 172.18.0.16" seconds after a veth appeared and vanished on
docker0, and every container lookup timed out from then on. The health service
retried for 77 minutes against a resolver that no longer answered.
Measured on dnsmasq 2.93 before landing this: a listen-address disables
--local-service even when that flag comes from the init script; without a bind
directive dnsmasq takes the 0.0.0.0 and [::] wildcard pair, which would collide
with a systemd-resolved stub and occupy port 53 on every address; bind-dynamic
and bind-interfaces both hold their per-address listeners across 2000 veth
cycles; and the stock dnsmasq.conf on arch, fedora and centos carries no
bind-interfaces, so the drop-in's bind-dynamic cannot abort startup there.
Two expectations in tests/unit/plugins/lookup/test_container_dns.py still encode
the compose-only asymmetry and fail with this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The previous probe counted effective directives in /etc/dnsmasq.conf and read
any of them as a foreign owner. Every distro ships a stock file with some, so
on centos and fedora the drop-in rendered without listen-address, dnsmasq bound
every interface via bind-dynamic and refused to start — both workspace jobs died
at router.yml:80 five minutes in, with byte-identical 244-byte drop-ins.
Deploy mode separates the two real cases: the swarm lab writes its own full
config that already serves every interface, so the drop-in must not narrow it;
everywhere else the role owns the listener as before. Same variable that already
gates TOR_CONTAINER_DNS_HOST.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The foreign-config probe looked in /etc/dnsmasq.d/ only, so a host whose
resolver is configured in /etc/dnsmasq.conf — the swarm lab — read as
unowned. The role then wrote listen-address=127.0.0.1 and narrowed a
bind-dynamic resolver that had been answering on every interface, leaving
nested image builds without a nameserver. Count the main config's effective
directives too, ignoring comments and the conf-dir line the role adds itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The egress router reached for ansible.builtin.package with a literal
name, which bypasses the per-distro mapping the registry exists for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map('split') chain returns an empty list on slurp-origin tagged
strings under ansible-core 2.20, so the dnsmasq forwarder was left with
no upstream and every clearnet lookup (image pulls, ACME) got REFUSED.
regex_findall reads the nameservers reliably.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
CI deploys on Arch runners died at the dnsmasq drop-in render: the pacman dnsmasq package ships neither /etc/dnsmasq.d nor an active conf-dir, so create the drop-in directory and enforce conf-dir in /etc/dnsmasq.conf (restarting dnsmasq when it changes). The self-reach gate then blocked every node without a web server on :80 (standalone svc-net-tor, dev environments): with the loopback shortcut an instant connection refusal proves the fast path works, so treat it as reached; likewise curl rc 52/56 over the Tor SOCKS require a completed rendezvous (descriptor published) and count as reached, while SOCKS failures and timeouts still fail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the inline/trailing comments added across the Tor egress-router, onion-domain, mailu-notls and vault round-trip changes, per the repo no-comments convention. Comment-only: no code behaviour changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The upstream-capture copy re-read /etc/resolv.conf on every run and filtered out 127.0.0.1. Once the router has pointed the resolver at dnsmasq (127.0.0.1), that filter yields an empty list, so the copy failed with "src (or content) is required" and aborted the deploy on re-runs. Guard the resolver read and the persist copy on the persisted upstreams file being absent (via a stat), so the capture runs only on first setup and later runs reuse the persisted upstreams.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route any outbound TCP to a *.onion address through Tor, protocol-agnostically (HTTP, SMTP, IMAP, ...), while clearnet traffic resolves normally and goes direct. Implemented on the target host and gated on TOR_EGRESS_ROUTER_ENABLED: torrc gains AutomapHostsOnResolve + AutomapHostsSuffixes .onion + VirtualAddrNetworkIPv4 + TransPort/DNSPort; a dnsmasq drop-in split-resolves *.onion via the DNSPort (clearnet upstreams embedded inline, since an AppArmor-confined dnsmasq may only read /etc/dnsmasq.d/**) and the host resolver points at it; an iptables nat OUTPUT rule redirects the automapped virtual range to TransPort. Verified in-container: http://<app>.<node-onion>/ returns 200 with no SOCKS wrapper, clearnet DNS + HTTPS stay direct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>