Commit Graph
32 Commits
Author SHA1 Message Date
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
kevinveenbirkenbachandClaude Opus 5 bf32f57b85 feat(svc-net-firewall): own the packet filter as one nftables table per role
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>
2026-09-15 20:02:36 +02:00
kevinveenbirkenbachandClaude Opus 5 fdaa31d6cb feat(svc-net-tor): carry mail to a .onion in both deploy modes
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>
2026-09-15 17:13:12 +02:00
kevinveenbirkenbachandClaude Opus 5 4f44eff128 fix(svc-net-tor): bind dnsmasq statically so a failed re-enumeration cannot drop every listener
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>
2026-09-11 12:01:51 +02:00
kevinveenbirkenbachandClaude Opus 5 28dd5462d7 fix(svc-net-tor): ask the running Tor for circuits instead of re-reading its config
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>
2026-09-09 23:21:03 +02:00
kevinveenbirkenbachandClaude Opus 5 bf20562cec style: apply make autoformat
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>
2026-09-04 03:40:17 +02:00
kevinveenbirkenbachandClaude Opus 5 5b22d6cb6d fix(svc-net-tor): let a refused name reach the zone's resolver
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>
2026-09-04 03:13:28 +02:00
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).

This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.

Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:

  * 7 `src:` values built from a role variable holding a bare filename
    (svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
    The variable doubles as the destination name inside the container, so the
    language segment belongs at the call site, not in the variable.
  * `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
    through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
    18 roles ship exactly that and have no template fallback: the play would
    have died on each.
  * `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
    interface now reads `files/javascript/javascript.js`.
  * `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
    path through `path_absolute(... 'files', ...)`.
  * 4 `ansible.builtin.script:` invocations naming a bare command
    (pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
  * `dev-python/files/shell/install.sh` pointed at a sibling role's script.

Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00
kevinveenbirkenbachandClaude Opus 5 bb0e098a9f fix(svc-net-tor): stop pinning the host to a single resolver
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>
2026-08-20 14:23:30 +02:00
kevinveenbirkenbachandClaude Opus 5 b09f2cbd82 fix(svc-net-tor): tie the dnsmasq listener to ownership, not the deploy mode
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>
2026-08-17 18:02:31 +02:00
kevinveenbirkenbachandClaude Opus 5 54aeba7e06 fix(svc-net-tor): name the clearnet upstream
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>
2026-08-17 18:02:30 +02:00
kevinveenbirkenbachandClaude Opus 5 afb78286ef refactor(svc-net-tor): drop the now-callerless external probe
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>
2026-08-06 10:29:10 +02:00
kevinveenbirkenbachandClaude Opus 5 f486269051 fix(svc-net-tor): drop dead external onion probe
The probe carried retries: 90, delay: 5 but failed_when: false, so it
could never gate a deploy — its only consumer was the debug warning
below it. Measured in CI run 30963648828: 4 blocks at 7:40/7:43/1:08/8:03,
283 retries, 3 of 4 ending non-fatal 'not reachable'.

Onion reachability is already gated where it matters, by
test-e2e-playwright's wait_onion_reachable.sh: it fails hard, budgets
2 x 40 x 15s, and force-recreates Tor for fresh circuits in between. In
the same job that gate ran 28 times for 6s total, while this probe was
reporting the onion unreachable.

The internal loopback probe stays and remains fatal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:02:23 +02:00
kevinveenbirkenbachandClaude Opus 5 d4f73dac7f fix(svc-net-tor): tie listener ownership to the deploy mode, not to config contents
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>
2026-07-31 01:00:13 +02:00
kevinveenbirkenbachandClaude Opus 5 439d06ff7e style(svc-net-tor): autoformat rewrap of the dnsmasq ownership expression
No semantic change; the formatter redistributes the same filter chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:49:05 +02:00
kevinveenbirkenbachandClaude Opus 5 c772d6537b fix(svc-net-tor): only claim the dnsmasq listener when nothing else owns it
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>
2026-07-30 15:09:59 +02:00
kevinveenbirkenbachandClaude Opus 5 989847a7cb fix(svc-net-tor): install dnsmasq through the package registry
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>
2026-07-28 09:18:37 +02:00
kevinveenbirkenbachandClaude Opus 5 b442a670f9 refactor(svc-net-tor): extract onion reachability tasks
The three onion-reach tasks repeated the same egress-router and
node-onion guard; they now live in onion_reach.yml behind a single
include condition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:12:38 +02:00
kevinveenbirkenbachandClaude Fable 5 5399b48482 feat(svc-net-tor): add a chutney flavor for private-net test deployments
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>
2026-07-24 19:31:16 +02:00
kevinveenbirkenbachandClaude Fable 5 cdd2bbf81c refactor(tasks): apply the 00_core numbering scheme to branch-local roles
svc-net-tor and web-svc-mirror predate the task-file numbering lint the
swarm-nfs merge brought in (a numbered *_core.yml must be 00_core.yml);
rename their 01_core.yml and retarget the main.yml includes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:00:42 +02:00
kevinveenbirkenbachandClaude Fable 5 e7834013d1 fix(svc-net-tor): extract resolv upstreams with regex_findall
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>
2026-07-22 17:41:21 +02:00
kevinveenbirkenbachandClaude Fable 5 9ae0c7eff6 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>
2026-07-22 15:19:52 +02:00
kevinveenbirkenbachandClaude Fable 5 d88f272f9f Merge branch 'feature/swarm-nfs' into feature/svc-net-tor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:31:08 +02:00
kevinveenbirkenbachandClaude Opus 4.8 6b40d63d30 fix(svc-net-tor): portable dnsmasq filter + non-fatal real-Tor probe
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>
2026-07-14 16:05:00 +02:00
kevinveenbirkenbachandClaude Opus 4.8 8a2e0f6a2e fix(svc-net-tor): portable dnsmasq drop-in + tolerant self-reach gate
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>
2026-07-14 13:58:16 +02:00
kevinveenbirkenbachandClaude Opus 4.8 dcd7f9861c fix(svc-net-tor): gate deploy on both onion paths + node-onion loopback shortcut
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>
2026-07-14 10:50:08 +02:00
kevinveenbirkenbachandClaude Opus 4.8 10a11fc570 fix(svc-net-tor): stabilise the .onion egress path for downstream deploys
- 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>
2026-07-13 02:34:00 +02:00
kevinveenbirkenbachandClaude Opus 4.8 c09eb61dc6 style: drop explanatory comments from the Tor onion changes
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>
2026-07-07 13:25:44 +02:00
kevinveenbirkenbachandClaude Opus 4.8 32daff8773 fix(svc-net-tor): idempotent egress-router upstream capture
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>
2026-07-07 11:24:56 +02:00
kevinveenbirkenbachandClaude Opus 4.8 62ffb10d8e feat(svc-net-tor): host-level transparent Tor egress router for .onion
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>
2026-07-07 08:22:01 +02:00
kevinveenbirkenbachandClaude Opus 4.8 945cf1a911 fix(svc-net-tor): apply torrc/key changes without the cross-role compose-up handler
The key/torrc tasks notified the sys-svc-compose "compose up" handler, which is
only registered when this role's later include_role of sys-svc-compose runs.
When svc-net-tor is loaded through sys-service-loader, its per-service
`meta: flush_handlers` fires before that include, so the notification resolves
to an unregistered handler and the play aborts ("handler compose up not found").

Register the key/torrc results instead and force-recreate the Tor compose
project inline when either changed (sys-svc-compose only recreates on
compose/env change, and the bind-mounted torrc is re-read at startup). No
cross-role handler dependency, so loader ordering can no longer break it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 03:26:03 +02:00
kevinveenbirkenbachandClaude Opus 4.8 e6538e4f61 feat(svc-net-tor): Tor onion-service provider (full onion node)
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>
2026-07-05 12:38:11 +02:00