The onion port probe gave every SOCKS connect 60s, half of Tor's
SocksTimeout (120s by default; torrc does not set it), although its own
docstring argued that abandoning an attempt before SocksTimeout is
destructive. Every attempt authenticates with fresh credentials, so Tor
builds a whole new rendezvous for it and keeps working on it until
SocksTimeout. The probe walked away first and called the port dead. The
other raw onion connects in the repository, the svc-db-* CLI tests and
test-e2e-playwright's wait_onion_reachable.sh, wait 120s per attempt.
Run 40128751852 shows how often an attempt needs more than that. Of 178
svc-net-tor CLI runs in its 89 compose onion jobs, three lost their
first attempt to the 60s timeout and connected on the second within
seconds (Shopware#1, Hugo#1, n8n#1; the CLI test took 77-79s against
the usual 1-16s), and Hugo#1's other run took 40s on a single attempt. When
the stall outlasts the 70s until the second attempt, the probe fails
while the HTTP probe reaches the same onion port seconds earlier:
OpenBao#1 (run 39166850273, job 118050491920, the original failure of
#1048) and Socialhome#1 (run 40088473634, job 121019758461). In the
Socialhome job tor.log holds no line for either attempt, because Tor
never reached its own verdict.
ONION_PORT_TIMEOUT is now 130: SocksTimeout plus room for Tor's
once-per-second expiry sweep, so an attempt ends with Tor's own answer
(reply 0x06, and a "Giving up" notice in tor.log, which the rescue
artifact captures) instead of the probe's. The second attempt now starts
at 140s and ends at 270s, so the probe rides out every stall the old
budget did, and any stall shorter than about 260s instead of about 120s.
A green run still costs one connect per port, and a refused or unmapped
port still fails at once. A unit test pins the window above Tor's
SocksTimeout and fails as soon as torrc sets its own.
That doubles what a dead port costs, and meta/tests.yml caps the whole
CLI test. The largest compose onion deploy in run 40128751852 probes 19
domains and 5 ports; with everything dead that is 19 x 350 + 5 x 260 +
10 = 7960s, beyond the 7200s that 069df39027 set for the same reason.
9000 keeps the full failure output for up to 22 domains.
Closes#1214
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tor starts its worker threads by the number of CPU cores it detects, and
the cpus limit of the container does not change that number. On a host
with 20 cores every node of the private chutney network ran 21 threads and
grew to about 0.6 GB. The seven relays and authorities filled the 2g
limit and its swap within ten minutes, the kernel killed them one by one,
and no circuit could be built any more.
The entrypoint now writes NumCPUs 1 into the torrc of every chutney node
before it starts the network. Measured on that host over ten minutes: the
nodes run 3 threads and stay at 65 to 86 MB, the container at 0.47 GB
without swap, where it had filled 2 GB plus 2 GB of swap before.
The CLI test of the role fails when a chutney node runs without the limit.
Closes#824
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
test-e2e-cli loads test.env with `set -a && . test.env && set +a`. The
unquoted `ONION_PORTS=25 80 110 ...` is not an assignment there but the
command `80` with a one-word env prefix: bash prints `command not found`,
leaves ONION_PORTS unset, and onion_ports.py reports "no onion-forwarded
ports declared; nothing to probe". The port check passed vacuously in every
tor job with more than one forwarded port (OpenResty, Listmonk, Pixelfed in
run 35661520542) and only ran where a single port was declared.
Pass the value through dotenv_quote, which quotes a space-separated value in
both compose and swarm and keeps the env-file lint satisfied. A unit test
renders the template in both modes and sources it the way test-e2e-cli does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dnsmasq binds the docker bridge address with the clearnet upstreams configured and the node's own .onion records loaded, and no rule covered port 53. Any source that could route a packet to that address got an open resolver plus the split-DNS records. c89ca42c46 claimed local-service closed this; abb6f37297 showed the keyword never took effect.
The guard now carries two named sets instead of one plus a special case. 'containers' is the docker pool list, for tor's own transparent and DNS ports. 'internal' is NETWORK_INTERNAL_CIDRS, for the resolver and the SOCKS port. Every guarded entry names its set, which folds the hand-written SOCKS rule and its manual append into the same list the reverse-path sets are built from.
The resolver takes the wider set deliberately: 'internal' is the list torrc.j2 already spends on SocksPolicy accept. The narrow set carries only 10.208.0.0/12 and 10.7.7.0/24 out of 10.0.0.0/8, which leaves out swarm's ingress network, every overlay docker allocates without an ipam block, and a node whose own address sits elsewhere in 10/8. A query bound to such an address was measured timing out against the narrow set. Guarding the resolver more strictly than the proxy beside it would refuse a name lookup to a source that is handed a full SOCKS proxy six lines earlier.
NETWORK_DNS_PORT lives next to the other resolver constants rather than in meta/services.yml: ports.local describes host-bound container ports, where 53 collides with svc-dns-unbound and falls outside every PORT_BANDS band.
Measured on the rendered artifact: the table loads and reloads with a byte-identical listing, every compose source of port 53 resolves inside 'internal' and every source of 9040/9053 inside 'containers', and a mistyped set name, an empty CIDR list or a missing admitted key each fail loudly at validate time. Port 53 newly passes a strict reverse-path check, so an asymmetrically routed client loses name resolution.
test_fragments built its guard list from a fixture of three entries while the role shipped six, so it validated the template against a port list nobody deploys. It now takes the shape from the declaration and fills only the numbers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EL10 and Fedora ship an active local-service=host in /etc/dnsmasq.conf. The drop-in repeated the keyword, and dnsmasq refuses a repeated ARG_ONE option: 'illegal repeated keyword at line N of /etc/dnsmasq.d//tor-onion.conf', then 'FAILED to start up'. Eight CI rows died at roles/svc-net-tor/tasks/router.yml:67, all of them on centos or fedora; arch and ubuntu start dnsmasq fine because their stock config leaves the keyword commented.
The line never took effect on any distribution. dnsmasq resets OPT_LOCAL_SERVICE as soon as an access-control option is present, and listen-address fills exactly that list; the template emits listen-address=127.0.0.1 unconditionally in the same block. A query from a source outside every directly attached subnet is answered identically with and without the line.
Port 53 therefore carries no source restriction. That is unchanged here and belongs in the nftables guard, which currently covers only the trans, DNS and SOCKS ports of tor itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SOFTWARE_NAME, SOFTWARE_DOMAIN, SOFTWARE_URL and ORGANIZATION are declared once in group_vars/all/00_general.yml and derive from each other. Eleven templates wrote the value instead, so a rename would reach the four declarations and every consumer that reads them, and silently miss the literal.
The lint reads the name from the declaration rather than carrying it, and searches case-insensitively: the domain is the name lowered, the URL is https:// plus the domain and the organization is the name again, so one literal finds the value of all four. A hit is reported with the variable it should have been, derived from the scheme in front of it and its case. The escape is nocheck: software-identity, same line or the one above.
Every migrated template renders the same bytes as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard trusted an address. rp_filter ships at all=0 and default=2 on this host and the repository sets it nowhere, so loose mode accepts a packet whose source is routable via any interface at all. A datagram claiming a docker pool address and arriving on the uplink passed both the kernel check and the admitted set, which for udp needs one forged packet and no handshake.
A strict reverse-path check on the guarded ports closes it. Measured in a namespace rig: a forged source routable via another interface reaches the input hook, because loose rp_filter lets it through, and the new rule counts it; a source routable nowhere never gets that far, the kernel drops it first. A loopback datagram to the same port does not match, so the host's own .onion resolution through 127.0.0.1 to the DNSPort is untouched.
Scoped to the four guarded ports rather than the whole chain. Strict reverse-path filtering on everything traversing input and forward breaks asymmetric routing, and this role runs on hosts that terminate wireguard tunnels.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit of what the host actually filters found a single input chain in the whole ruleset, covering three tor ports. ss showed two more binds with nothing in front of them: the SOCKS port on 0.0.0.0, and dnsmasq on the bridge address.
Each gets the layer it belongs in. SOCKS gets a packet filter rule against the same CIDRs SocksPolicy already accepts, so no client loses access while the filter closes if tor's own config ever stops enforcing. Reusing the narrower guard set was deliberately avoided: it excludes the LAN blocks SocksPolicy admits, so it would have changed the policy rather than hardened it.
dnsmasq gets local-service, which answers only directly connected networks. A packet filter rule there would have sat on the container DNS path, which daemon.json points at that same bridge address, in exchange for closing an exposure that needs an extra route onto the bridge.
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>
The list form could publish a host:container pair and nothing else, so thirteen blocks stayed hand-written: the ones needing a protocol suffix, a port range, the swarm long syntax with mode host, or a container-port-only publish. Each of those duplicated numbers the role already declares in meta/services.yml, which is the drift the lookup exists to remove.
The lookup keeps the list form unchanged for its 93 existing call sites and gains a dict form: transport emits one item per protocol, container 'same' serves the services that cannot remap (STUN/TURN, media relays), mode 'host' emits the long block an edge role needs in swarm so the client address survives the hop, publish 'ephemeral' emits the container port alone, and key names the block for Discourse's launcher file, whose expose: list is what becomes docker run -p there. A declared {start, end} mapping renders as start-end on both sides, so a relay range needs no term of its own.
svc-prx-openresty had no ports block at all and bound 80 and 443 as literals in its template, while torrc.j2 carried a third literal 80 for the hidden service. None of the three referenced the others. openresty now declares the ports, the compose template reads them, and the onion's port 80 comes from ports.onion.http through TOR_ONION_EXTRA_PORTS.
Three blocks stay raw, each with the reason it cannot route through one call: keycloak's sso_proxy maps a published port owned by the consuming role to a container port owned by web-app-keycloak, and matrix's ansible flavor takes element on 8080 and federation on 8048 from the upstream playbook rather than from this repo's ports.internal.
The enforcing lint scanned only files with compose in the name and only double-quoted list items, so nine of the thirteen blocks were invisible to it. It now reads every role template that can carry a ports block, catches unquoted items, protocol suffixes, ranges, flow style and the long syntax, and tracks the block by the indentation of its key: keying on the next line closed the block on anything shaped like a key, which target, published, protocol and mode all are. Under expose: only a real mapping counts, because there the single-segment form documents a container port and publishes nothing.
Verified by rendering all 110 container_ports call sites through the real templar, the real lookup and the real merged applications view.
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>
The healthcheck ran `tor --verify-config`, which parses the torrc and never
speaks to the daemon. Ansible renders that file from a template, so it is valid
by construction and the probe could not fail once it existed: bootstrap
progress, circuit establishment and descriptor publication were all invisible,
and `retries` was a dead parameter. Swarm therefore reported the service healthy
while Tor was still coming up, and the readiness that actually gates deploys had
to be rebuilt beside it, in onion_self_reach.py and playwright's
wait_onion_reachable.sh.
The torrc already opens a cookie-authenticated control port, so the probe now
authenticates there and asks GETINFO status/circuit-established. That is the
question the container is supposed to answer. It needs no package the image
lacks: bash carries /dev/tcp and od hex-encodes the cookie, both present in
debian:trixie-slim.
Measured against real Tor, the public flavor reports healthy 6.1s after start
and chutney 54.6s, each confirmed by "Bootstrapped 100% (done)" in the container
log. Chutney is the slower of the two because its entrypoint brings up a whole
private network and waits for consensus before the node tor starts; its
localhost advantage only pays off afterwards, at publish and rendezvous. Both
fit ten minutes with margin to spare, so the flavor-dependent start window goes
and every timing is now the platform default. Three nocheck markers go with it,
including an interval of 60s whose own reason said the probe was "worth running
rarely" while setting twice the default frequency.
Two paths and one condition that had grown copies are pinned in vars/main.yml.
The Dockerfile takes the script locations as build args rather than repeating
them, reaching the entrypoint through a fixed symlink because ENTRYPOINT in exec
form would not expand the arg and would silently keep pointing at the old path.
One behaviour change worth stating plainly: a Tor that never establishes a
circuit is now reported unhealthy after the start window, where before it was
reported healthy forever. That is the point of the change, and it moves such a
failure from the later Playwright run to the service itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
start_interval is the cadence while start_period runs, and only while it runs.
Once the window closes the probe falls back to interval, so a service that
becomes ready just after it pays a whole tick anyway and the cadence never
reached it. Six of the seven flavors were shaped that way, Curl worst: a thirty
second window against a one minute interval.
Measured across 151 converge waits in four job logs, 63 sit exactly on a tick
boundary, 36 in the thirty second step and 27 in the sixty second one.
Attributed by application, web-app-seaweedfs is the largest at 1501s over
sixteen waits with a median of 57.7s: it probes with curl and fell straight into
the gap. web-app-zammad probes with connect, whose window is fifteen minutes,
and its 154s median is real boot time with nothing to recover.
So the window moves to ten minutes and the steady state interval to two, and the
flavor overrides that sat below them go: Curl, Wget and Connect lose interval
1m, Http its twenty second window, Nc its ten. Connect keeps fifteen minutes.
Neither number costs anything while a container works. start_period delays
nothing, it only stops failures counting, and interval no longer touches
convergence at all since that hangs on the window alone; it is steady state
load, which drops from 120 probes an hour per service to 30.
Forty six role files follow. Twenty seven declarations go that repeat the new
default or undercut it, an undercut window being the defect itself. Eight
windows longer than ten minutes keep their value and now say why. The literal
blocks and the three Dockerfiles this repository builds spell every timing out,
because outside the lookup an omission takes docker's value and not the
platform's.
A fourth lint rule keeps the shape: start_period must outlast one interval,
checked against the effective values, so raising interval without the window
cannot quietly bring the cliff back.
Two faults in the lint surfaced while building it and are fixed here. It
compared timings as strings, so 600s and 10m read as different values, and it
matched the healthcheck key exactly, so a line carrying a trailing nocheck was
skipped whole, which is how the two swarm-init partials went unexamined.
One regression, bounded. A container that starts and never turns healthy is
neither rejected nor errored, so stack_ready.sh only calls it fatal after three
killed tasks, and each cycle now spans the longer window. That reaches twenty
minutes rather than six, capped there by swarm_stack_converge_retries at 600
with a two second delay, and it lands only on runs that were failing anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A healthcheck written outside the container_healthcheck lookup inherits nothing
from utils/docker/healthcheck, and docker offers nowhere to put a replacement.
daemon.json carries storage, logging, DNS, registries and runtimes, and no
health key at all, so an omitted timing lands on docker's own value rather than
the platform's. The two disagree where it matters most: docker starts a
container with no start period, which leaves start_interval no window to act in.
That is why the hand-written blocks never gained the cadence.
The three images this repository builds itself now carry it. openldap and
postgres also drop to the platform interval, and bluesky gains the start window
it never had:
svc-db-openldap/files/Dockerfile --interval=30s ... --start-interval=5s
svc-db-postgres/templates/Dockerfile.j2 --interval=30s ... --start-interval=5s
web-app-bluesky/.../Dockerfile --start-period=30s --start-interval=5s
Six literal blocks move to the platform interval as well: magento, redis,
elasticsearch, matrix and both sys-svc-rdbms partials. Since start_interval
exists, interval no longer touches convergence and is steady state load alone.
Sixteen values that stay where they are now name the mechanism that keeps them
there. Two are worth reading. web-app-kix overrides start_period and nothing
else on purpose, letting the image's own HEALTHCHECK supply test, interval,
timeout and retries; filling those in would have overwritten what upstream tuned.
The memcached and redis swarm-init partials probe every second and deliberately
run with no start window, which is why start_interval is absent rather than
forgotten.
The rest name their probe: occ boots the whole Nextcloud stack, tor re-parses
its torrc instead of asking a running service, opensearch is still forming a
cluster, matrix waits on a file another step writes to a share.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
42ebd9ea71 gave the healthcheck SPOT start_interval, the cadence docker uses
while start_period is still running. A block a template writes out itself
bypasses the SPOT and never gained it. Measured across the repository: 46 such
blocks, 40 of them already carrying start_period, not one carrying
start_interval.
Without it a probe fires only every interval, so a service ready after two
seconds is reported healthy at the next tick and a stack converges no faster
than its coarsest quantisation. That lands in sys-svc-compose's swarm converge
wait, 1551.7s per CI job over 38 stack deploys.
Twenty five of the blocks move into meta/services.yml and render through
container_healthcheck. Each keeps an explicit test rather than picking a flavor:
a flavor would replace the command instead of carrying it across.
The move is lossless by construction. probes.py block() writes
overrides.get(key, default) for interval, timeout, retries, start_period and
start_interval, so a declaration repeating the literal values renders the same
block plus the missing cadence. Checked per block against the old values: the
only difference is start_interval 5s, nothing changed, nothing lost.
Twenty one stay literal. Seventeen interpolate a template variable, whose value
resolves in a scope meta/services.yml does not share, so moving them would be a
rewrite rather than a transcription. Four interpolate nothing and still cannot
move: svc-db-memcached and svc-db-redis probe every second in their swarm
service partial, where a 5s start cadence would report healthy later than the
block already does, and the sys-svc-rdbms and keycloak sso_proxy partials render
for the consuming role's application_id, so their declaration has no single
meta/services.yml to live in. Each carries a nocheck naming its reason.
All twenty one gain start_interval. Three also gain start_period, without which
the key does nothing at all, since it only applies while the start period runs.
That widens their failure tolerance rather than narrowing it: bluesky from 90 to
120 seconds, magento from 50 to 80, the mariadb partial from 180 to 210.
pixelfed spelled its probe as a bare string and now spells it CMD-SHELL. Docker
reads both as a shell command; only the YAML shape changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9ae0c7eff6 replaced bind-interfaces with bind-dynamic when it taught the role to bind the node address for swarm, an address that appears after dnsmasq starts. Without a bridge address there is nothing to wait for, and the rebuild bind-dynamic performs on every netlink event can then only drop the listener: on the DiD host of run 33698844864 job 100479883964 the sampler lost dnsmasq's 127.0.0.1:53 for four consecutive samples one second after a veth unregistered.
interface=lo is deliberately not added. dnsmasq unions interface= and listen-address= rather than intersecting them, measured on 2.93: naming lo additionally seizes 127.0.0.53 and ::1, and against a distro stub dnsmasq exits 2, which any_errors_fatal turns into a failed deploy.
The resolv.conf keeps both nameservers. bb0e098a9f added the second one from a measured total outage where dnsmasq held no socket at all, and that failure mode is untouched by this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six findings from a full audit of the roles this branch touched. None was
behind a failing job; all of them lie about what the code does.
Two defaults that can never fire, and would do harm if they did.
vars/main.yml gave services.tor.flavor a 'public' fallback, but
meta/services.yml:28 always defines the key - so the fallback is
unreachable, and it defeats the assert at tasks/00_core.yml:4-9: remove
the key and the role silently deploys public instead of failing loudly.
templates/dnsmasq-tor-onion.conf.j2 guarded twice on
`TOR_DNSMASQ_OWNS_LISTENER | default(true)`, while tasks/router.yml:35-37
sets that fact unconditionally two tasks earlier; had it ever gone
missing, `true` would have reshaped a dnsmasq this role does not own.
Two tasks with no readers. sys-front-inj-logout/tasks/01_deploy.yml
stat'ed logout.js to build INJ_LOGOUT_JS_VERSION; neither that fact nor
INJ_LOGOUT_JS_STAT is referenced anywhere under roles/ or group_vars/.
Cache busting already runs independently through lookup('local_mtime_qs')
in templates/head_sub.j2:1.
One README claim inverted. svc-net-tor's feature list named
TOR_EGRESS_ENABLED and called it off by default. That variable exists only
in docs/requirements/031-*.md - the implementation settled on
TOR_EGRESS_ROUTER_ENABLED, which group_vars/all/19_tor.yml:4 sets to true.
The README stated the opposite of its own default under a name nothing
reads.
Three files nothing can load, in web-app-prometheus. tasks/reset.yml is a
single comment and is included nowhere; templates/compose.override.yml is
one byte, and sys-svc-compose/tasks/04_files.yml:52-54 searches only
templates/compose.override.yml.j2 and files/compose.override.yml;
templates/style.css is a single comment, and sys-front-inj-css/tasks/main.yml:10
searches only templates/style.css.j2 and files/style.css.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe abandoned each attempt at --max-time 30 while Tor's SocksTimeout
sits at its 120s default (no SocksTimeout anywhere in the repo; positive
control SocksPort hits templates/torrc.j2:1). It gave up at a quarter of
Tor's working window, and destructively: every retry opens a fresh SOCKS
connection, discarding the descriptor fetches and rendezvous retries Tor
was still working through.
Run 32284541284 job 96178512465 shows the cost. Ansible recorded start
07:22:17.260, end 07:26:33.366, delta 0:04:16.105994. The first domain
burned its deterministic 6x30 + 5x10 = 230s and failed; the second then
succeeded on its first attempt but needed ~23s of that 30s window - 77%
of the limit. The transient had cleared at t~233s. The first domain's
budget ran out three seconds short of it.
Two round numbers, one behaviour: 5 attempts at 60s is 340s of patience
against today's 230s, so the same transient now has 107s of margin
instead of -3s, and a 23s connect sits at 38% of the window instead of
77%. LAM#1 (job 96179137200) is the near-miss that corroborates it - four
dead attempts, then a ~4s success on the fifth.
meta/tests.yml is required, not optional. roles/test-e2e-cli/tasks/run_one.yml:74
runs the script under `timeout {{ _cli_timeout }}`, and with no meta/tests.yml
this role fell through to the 3600s default in roles/test-e2e-cli/defaults/main.yml:3.
Widening the window moves the all-domains-fail ceiling from 16 domains to
11, and a 13-domain role demonstrably exists (Jellyfin#0, four passes of
"13 probed, 0 failed"). 7200 restores the headroom.
The `|| true` goes with it: the script runs under `set -uo pipefail` with
no -e, so the command substitution's exit code was already discarded and
line 63 branches on ${code}. tests/lint/filesystem/shell/test_no_or_true.py
never saw it because its regex wants #, ; or EOL after `true` and a `)`
follows here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mount pipeline was split across two layers: compose_volumes and
container_volumes lived as filter modules that ansible's filter loader
rejected on every start ("has no attribute 'FilterModule'", two warnings
per run), while the same-named lookups wrapped them thinly. The filter
bodies now live inside their lookups (the tor_socks precedent), the
filter files are gone, and the render tests moved next to the lookup
tests.
As lookups they see the templating context, so the application_id term
every call site passed back to its own role is gone too: both lookups
read application_id from context and fail loudly when it is missing.
107 templates and nfs_prep.yml drop the term; the calls collapse to
lookup('compose_volumes') and lookup('container_volumes', service_name).
The container_volumes call is now canonical - four leading spaces,
service_name in the service position, | indent(4) - enforced by a new
lint (test_container_volumes_call_shape). Normalizing the 85 literal
call sites surfaced that erpnext keyed its meta/volumes.yml mounts by
container name instead of compose service key, the only role to do so;
its meta now uses the service keys. wordpress's column-0 call, which
rendered its first line at top level, is indented like every other.
Template logic that only restated meta/volumes.yml is deleted: mailu fed
compose_volumes an extra_volumes dict whose eight names were looked up
from the very meta file compose_volumes reads itself; zammad's init and
railsserver extra mounts were the volumes.yml entries minus the two
services, which the meta now lists (the when-gated OIDC binds included).
Conditionality that meta cannot express stays in code, but in role-local
lookup plugins instead of template loops: matrix_bridge_mounts renders
the per-bridge registration mounts, nextcloud_extra_volumes the
feature-gated whiteboard/recording volumes (type: volume entries are
declared unconditionally by compose_volumes, so gating must happen
before it).
Parity is proven, not assumed: every mechanically rewritten template is
byte-identical to HEAD after the transformation regexes (95/95), and the
semantic roles render identically through the real functions against
HEAD's meta - zammad across six sso/tls polarities, all seven erpnext
services, mailu's top level. make test-lint passes complete.
Also: suitecrm's compose template pins container_extra_hosts with
host_alias (the SOCKS proxy URL host.docker.internal never resolved in
compose+tor), and the five disabled mautrix bridge addons use the
empty-default config lookup so templating the applications dict stops
emitting 125 errors per batch in every CI job.
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 repository env lint requires ascending keys in every env file, and
test.env.j2 listed TOR_SOCKS ahead of TOR_DNSMASQ_CONF, so make quality
stopped there. Order carries no meaning in a dotenv template: Jinja
resolves the values from the role vars, not from preceding lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Job 94849355639 of run 31823845576 failed on exactly one name: the bare apex
infinito.example, HTTP 000, "Could not resolve host", while
espo.crm.infinito.example answered 200 in the same second through the same
resolver. The probe printed dns= and stopped there, so the log named the
symptom and no artifact carried the cause. There is no dnsmasq log
(log-queries is off, the journal has zero dnsmasq lines), no CoreDNS log in
the compose artifact set, and no query output from the host at failure time.
Reproducing the resolver stack locally did not close it either. CoreDNS on the
CI Corefile, dnsmasq on the drop-in pulled byte-identical from that job's own
--diff at 20:58:35, and the full dnsmasq -> 127.0.0.11 -> CoreDNS chain with a
live forward path all answer the apex with 172.30.0.10. The configuration is
exonerated; what is missing is the transaction itself.
So the failure branch now prints the active resolver, any /etc/hosts match,
what the local dnsmasq answers, and what each upstream answers. The last two
are the split that matters: they separate "dnsmasq dropped it" from "upstream
dropped it", which is the fork the bench could not settle. Upstreams are read
from the drop-in directory rather than one file, so a shared dnsmasq carrying
foreign drop-ins is covered, and matching only bare-IP server= lines skips
server=/onion/127.0.0.1#9053.
TOR_DNSMASQ_CONF arrives through test.env instead of a hardcoded
/etc/dnsmasq.d, using the same :? idiom 8d7fff4d2 gave the other four values,
so the path keeps its single source in vars/main.yml. dig is opportunistic:
without bind-tools the resolver and hosts lines still land.
Verified: bash -n and shellcheck clean, the failure path exercised end to end
against a CI-shaped drop-in with 127.0.0.11 extracted and the /onion/ line
correctly skipped, test_comments_valid passed.
Two things this does not close. dig is probably absent on the Arch target that
failed, since the bind9-dnsutils lines in that job come from a Debian build
stage, not the target; adding bind-tools to the role would fix that and is a
separate concern. And the trigger is now rare, because pinning tor true across
the espocrm variants removed the mixed round that put the clearnet apex into
the vhost list at all. This is insurance, not a plan that pays out next run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
meta/volumes.yml says where a volume lands inside its container. Wherever a
second file spelled the same path out, the two drifted independently: the mount
moves, the consumer keeps pointing at the old place, and nothing fails until the
process looks for content that is not there. e031b7f61 fixed exactly this for
web-app-moodle; this generalises it and makes the rule hold.
The lookup gains what a caller needs. `targets` maps each mounting service to
its container path, and `target` is the one path an entry mounts at -- present
only when every mount agrees. An entry that lands at different paths per service
omits `target` rather than picking one, and the caller names the service:
"{{ lookup('volume', application_id, 'prometheus_config').target }}"
"{{ lookup('volume', 'web-app-prometheus', 'host_sys').targets['node-exporter'] }}"
66 literals across 48 role files now derive. Replacement only happened where the
path stood alone -- /srv/app inside /srv/app/data is a different path -- and
every rewritten file was re-parsed, Jinja and YAML alike. Three sites the tool
refused rather than guess were done by hand: zammad's path sat in a Jinja list
literal, seaweedfs' inside a quoted string feeding a filter, and openresty's
/var/run/docker.sock is not a mount target at all but the socket on the host,
which only happens to spell the same string. That one carries the single
nocheck.
tests/lint/repository/spot/test_volumes_path_single_spot.py keeps it that way.
roles/*/files/ is out of scope by construction: it is copied verbatim into an
image and executed there, so a Dockerfile or entrypoint has no Ansible to
evaluate a lookup with.
One defect this found, and it was in the tooling rather than the roles: deciding
ambiguity AFTER filtering short paths made web-app-prometheus' host_sys look
single-valued -- it mounts /sys for cadvisor and /host/sys for node-exporter,
and /sys is below the length filter. The template would have rendered an
undefined `.target`. Both the converter and the rule now decide ambiguity over
every target and filter afterwards, and that call reads targets['node-exporter'].
Verified by resolving rather than by reading: all 70 accesses in the diff were
run against the real registry and every one yields a non-empty path equal to the
literal it replaced. make test-lint: 328 passed.
Not verified: no Ansible render, so a call site whose surrounding context cannot
build the applications registry would still fail at deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bind-dynamic sat outside the {% if TOR_DNSMASQ_OWNS_LISTENER %} guard that
gates the listen-address lines, and router.yml:72 forces that flag false in
swarm. So on a stack host the drop-in declared it does not own the listener
and then dictated its bind mode anyway -- naming no address at all.
That converts a wildcard-bound instance's sockets into enumeration-derived
ones. When an IPv6 netlink dump aborts, dnsmasq skips the AF_INET pass and
its GC closes every IPv4 listener, silently: the message is LOG_DEBUG and
bind-dynamic bind failures are non-fatal. The node was left with 10 IPv6
listeners and zero IPv4 while four IPv4 addresses existed, and router.yml
had already pointed /etc/resolv.conf at 127.0.0.1 -- so dockerd got ICMP
port-unreachable and could not resolve ghcr.io. 33 failures in 11 groups,
none before the drop-in landed. The two worker nodes, which never received
it, kept 0.0.0.0:53 bound throughout.
Ownership is the right axis, not deployment mode. Where the role owns the
listener, bind-dynamic is load-bearing: a listen-address naming docker0 can
disappear across a docker restart, and without OPT_CLEVERBIND that bind is
fatal. Where it does not own the instance, the directive is pure collateral
-- and next to a base config carrying bind-interfaces it aborts startup
outright.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting the external onion probe left check_external, _CURL_REACHED_RCS,
the internal|external|both dispatch and the optional socks argument with
no caller, while the docstring still promised 'Exit 0 only when both
succeed' and that the external path 'must actually work end to end' —
the exact contract that deletion removed.
The script now takes one argument. Real-network reachability stays gated
where it blocks something: test-e2e-playwright's wait_onion_reachable.sh.
Wiring the CLI test into e2e-cli also armed its default retry budget:
20 attempts x curl --max-time 30 plus 19 x 15s sleep is 885s per failing
domain, serial. TEST_E2E_CLI_TIMEOUT is 3600s, so four failing domains
exhaust it and a tor deploy serves about twelve vhosts. test.env.j2 now
pins RETRIES=6 and SLEEP_SECONDS=10 — 230s per domain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The role shipped a complete probe script at files/test.sh and no
templates/test.env.j2. discover_cli_roles globs the env template alone,
so the role was never discovered and the script had never run — and the
script hard-requires TOR_SOCKS and NGINX_SERVERS_DIR via ${VAR:?}, so it
could not have run without one either. Its own usage hint pointed at a
path that does not exist.
Moving the script to the discovered path and adding the env template
makes both halves line up. Verified against the live stack: the onion
answers through the Tor SOCKS proxy, 1 probed, 0 failed.
The new lint keeps the pair together and rejects a script left at
files/test.sh. The two halves fail differently and only one is loud: an
env template without a script aborts the play, a script without an env
template is never discovered and its silence reads like a pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reaching an onion address required each role to point its own http client
at the tor socks proxy, which then carried clearnet traffic too — fatal
in the test network, which has no exit, and the reason nextcloud needed a
per-host exclusion list.
The egress router already redirects tor's virtual range to the TransPort
and dnsmasq already forwards .onion to tor's DNSPort; the piece missing
was reachability. dnsmasq listened on loopback only, which docker
discards, so containers never saw it. It now also answers on the docker
bridge, the daemon points there with the clearnet resolver kept as
fallback, and the role's own onion resolves to a container-reachable
backend instead of loopback.
Nextcloud drops its proxy block and the exclusion list; every other role
gets the same behaviour without configuring anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test variant (meta/variants.yml, variant 0) switches the tor
service to the chutney flavor: the image ships chutney and the
entrypoint boots a private Tor network inside the container (all nodes
on 127.0.0.1, so the design is identical under compose host-networking
and swarm). The entrypoint bootstraps from /opt/chutney (chutney
creates its net/ state CWD-relative), extracts the DirAuthority/
TestingTorNetwork lines into /run/tor/chutney-net.conf and fails fast
when that file ends up empty, since the node would otherwise silently
join the public network. torrc %includes the conf only for the chutney
flavor.
Descriptor publish and rendezvous now run at localhost speed: per-app
onion e2e suites dropped from minutes to seconds in the compose
validation. The test variant also raises the footprint to
2g/1.0cpu/512pids (measured: at 1g/0.25 the ~9 extra tor nodes pin the
caps and desc publishing stalls); meta/services.yml stays static on
the public flavor and the old production footprint.
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>
The Playwright onion gate probed the app onion over Tor and silently
SKIPPED the whole suite when it did not answer — a broken onion could
go green. Remove the gate: the probe now waits hard (40x15s, covering
normal 2-5 min descriptor publication) and an unreachable onion fails
the deploy loudly. Reachability is part of what is under test.
The readiness probe treated transport-level failures (status: -1) as
'< 500', satisfying its until condition on attempt 1 and never using
its retries (nextcloud [0,1] died to TLSV1_UNRECOGNIZED_NAME this way).
Require a real HTTP status >= 100 so transport errors retry.
Tor pins one long-lived entry guard; its own log showed ours degraded
('failing more circuits than usual ... 38 collapsed, 21 timed out'),
which throttles every flow of the node at once — the most plausible
cause of the 89-minute descriptor-publication outage and the
intermittent ERR_SOCKS_CONNECTION_FAILED in E2E. New config key
services.tor.use_entry_guards (default true keeps the Tor default and
production anonymity posture); the matrix variant pins it false so test
deployments render UseEntryGuards 0 and a single bad relay cannot stall
the node. Anonymity is not under test there, only reachability.
Verified: full svc-net-tor closure deploy over onion with the gateless
semantics, failed=0, every onion suite executed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
filter-rr was added in dnsmasq 2.86, so it aborts dnsmasq startup on distros shipping older builds (CentOS Stream 9 has 2.85). Use filter-AAAA instead, which parses on every CI distro (dnsmasq 2.85 through 2.93, verified per-distro with dnsmasq --test).
Split onion_self_reach.py into internal/external/both modes. Keep the internal loopback fast path as the hard gate (later host-side steps resolve the onion through it), and probe the real Tor path best-effort (retries the full ~450s publish window, failed_when: false, warns): descriptor publication is async, needs an app backend behind the onion, and is blocked on CI runners with no Tor egress, none of which should fail a deploy. Playwright retries the real onion with its own onion-scaled timeouts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the internal-only reachability one-liner with onion_self_reach.py, which gates the role until the node onion answers both internally (host resolver loopback fast path) AND over the real Tor SOCKS (descriptor published — the path Playwright/users take). Add a dnsmasq address= shortcut mapping the node's own onion suffix to loopback so host-side deploy steps skip the Tor circuit; other .onion still egress via Tor and container/Playwright traffic (Tor SOCKS) is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- dnsmasq: filter-rr=AAAA on the .onion split-DNS. Recent Tor answers the
AAAA automap with a link-local-shaped v6 address that has no REDIRECT rule,
so clients that prefer v6 (getaddrinfo) black-holed every onion request.
- self-reach gate: after wiring the egress router, block the role until the
node onion actually answers over Tor. A (re)started Tor republishes its
hidden-service descriptor for ~2-5 min; without the gate every later step
touching an onion URL (front-proxy checks, injections, Playwright) failed
as if transient. Any HTTP reaction — including OpenResty's 444
connection-close for the bare node onion — counts as reachable.
Co-Authored-By: Claude Opus 4.8 <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>
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>
meta/services.yml gains tor.node (node onion address, filled per environment by the inventory) and tor.ports.local.socks (SOCKS port single source of truth). torrc renders SocksPort via the tor_socks lookup; the role test.sh takes TOR_SOCKS from the environment instead of a hard-coded 127.0.0.1:9050.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn a NAT/edge node into a full Tor v3 onion node: DOMAIN_PRIMARY
becomes the node's .onion, so the whole stack (web, SSO, LDAP, CA)
resolves onion domains and is reachable over Tor without a public IP,
port forwarding, VPS or DynDNS.
- utils/tor_onion.py: offline v3 key mint (address + HiddenServiceDir
key files), verified against a known key->address test vector.
- cli .../inventory/onion: `init-env` pre-mints the node onion, writes
INFINITO_DOMAIN(+_RE) into .env and authoritative key files into
.onion-identity/hs (idempotent). The onion is a deploy-time input;
no in-deploy random minting (that would not match the pre-set domain).
- roles/svc-net-tor: Tor sidecar (Debian-built, network_mode: host),
installs the minted keys, torrc with one node HiddenService + a SOCKS
proxy restricted to loopback/DiD ranges. files/test.sh probes every
served onion domain over the Tor SOCKS.
- tls: .onion -> TLS off (onion v3 crypto is the transport auth; LE
cannot sign .onion) + app-level flavor override.
- harness/DNS: coredns resolves the onion zone via INFINITO_DOMAIN;
skip the clearnet DNS sanity check for onion; raise nginx
server_names_hash_bucket_size to 128 for long onion server_names.
- test-e2e-playwright: gated browser SOCKS proxy (PLAYWRIGHT_PROXY) +
navigation/action timeouts so specs reach .onion over Tor and fail
fast; the onion node points it at the Tor SOCKS.
- docs/requirements/031: full design.
Verified: dashboard + SSO (Keycloak) + 9 apps reachable over Tor
(HTTP 200 through the node hidden service). make test green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>