031 - svc-net-tor: Full Tor Onion Node¶
User Story¶
As a self-hoster running Infinito.Nexus behind NAT/CGNAT/dynamic IP (Raspberry Pi, edge node, home server), I want a svc-net-tor role that makes the node’s primary domain a Tor v3 .onion address so that the entire stack (web, SSO, LDAP, CA) is reachable over Tor without public IP, port forwarding, VPS, or DynDNS — while individual apps can still attach extra public domains with their own TLS.
Model¶
DOMAIN_PRIMARY = the node’s .onion address. Everything derives from DOMAIN_PRIMARY, so the whole node becomes Tor-native with zero domain-transform code:
Vhosts:
next.cloud.{{ DOMAIN_PRIMARY }}→next.cloud.<NODE_ONION>.onionOIDC issuer, redirect URIs, web-origins: onion — one realm, one issuer, consistent
LDAP_DN_BASE, user/admin emails, oauth2-proxyemail_domains, CA identity: all onion, mutually consistent (users@<NODE_ONION>.onionpass the oauth2-proxy email filter)
The .onion is a deploy-time input: a CLI helper mints the v3 key offline during inventory build, vaults it, and sets DOMAIN_PRIMARY. It must NOT be a runtime fact — DOMAIN_PRIMARY is consumed at build time (LDAP base, CA, applications cache). The role restores the minted key into the hidden-service volume before Tor starts.
All-or-nothing: a partial shift (web onion, identity clearnet) breaks oauth2-proxy email matching (oauth2-proxy-keycloak.cfg.j2:32 sets email_domains = "{{ DOMAIN_PRIMARY }}"). Fresh onion nodes only; migrating an existing clearnet node is out of scope (base DN + all identities would shift).
Codebase facts (verified; build on these)¶
Fact |
Where |
|---|---|
OpenResty runs |
|
TLS-disabled vhosts serve plain HTTP on |
|
TLS is resolved per |
|
|
|
Keycloak redirect URIs/web-origins are computed from |
|
Apps register one vhost per domain via |
|
Extra app domains live in |
|
Inventory build/secrets tooling (mint-helper integration point) |
|
Container-service role archetype (compose sidecar, |
|
Role file/entry policy enforced by lint (mandatory files, galaxy_info canon, README sections + exact Credits block, lifecycle values, service bond) |
|
No prior server-side Tor art in the repo ( |
repo-wide grep |
Tor facts (verified upstream)¶
Subdomains:
sub.<addr>.onionroutes to the same hidden service; the client sends the fullHost:header → vhost routing works exactly like clearnet. (address-spec, RFC 7686 §2)v3 address algorithm (needed for offline mint):
address = base32(PUBKEY ‖ CHECKSUM ‖ VERSION) + ".onion",CHECKSUM = SHA3-256(".onion checksum" ‖ PUBKEY ‖ VERSION)[:2],VERSION = 0x03, PUBKEY = 32-byte ed25519 master pubkey.Key-file formats in
HiddenServiceDir:hostname(address +\n),hs_ed25519_public_key(== ed25519v1-public: type0 ==header + 32-byte key),hs_ed25519_secret_key(== ed25519v1-secret: type0 ==header + 64-byte expanded secret key = SHA-512(seed) clamped).http://*.onionis a Secure Context →Securecookies + secure-context APIs work over plain-HTTP onion; hence forwardX-Forwarded-Proto: https. (W3C Secure Contexts).onionis in the Public Suffix List →user@x.onionpasses most validators.No Let’s Encrypt for
.onion(ACME needs public DNS). Onion transport is already encrypted + server-authenticated (address = pubkey).
Decisions (operator-confirmed; do not re-litigate)¶
# |
Decision |
|---|---|
1 |
Role |
2 |
Full onion node: |
3 |
One node key + per-app subdomains ( |
4 |
Tor = compose sidecar, |
5 |
HTTP on the onion side (TLS forced off for |
6 |
Offline Python mint at inventory build: ed25519 → address → exact Tor key-file formats → vault → |
7 |
SSO needs no special handling: Keycloak domain is onion ⇒ one onion issuer/realm; onion emails match |
8 |
Tor image built via |
9 |
Per-domain TLS via app-level override: extra public domains under |
10 |
|
11 |
CI runs a real Tor E2E ( |
Target Schema¶
Role layout¶
roles/svc-net-tor/
├── README.md # lint-conform (sections, exact Credits block, no heading emojis)
├── files/Dockerfile # Debian base + pinned `tor`
├── meta/
│ ├── main.yml # canonical galaxy_info (author/license/company/platforms exact canon)
│ ├── services.yml # `tor` entity (below)
│ └── volumes.yml # tor_data -> HiddenServiceDir
├── tasks/
│ ├── main.yml # run_once_svc_net_tor guard
│ ├── 01_restore_key.yml # vaulted hostname + hs_ed25519_* -> volume, strict perms, never overwrite
│ └── 02_core.yml # network routine + compose up + render torrc (handler-driven reload)
├── templates/
│ ├── compose.yml.j2 # sidecar: network_mode host, tor_data volume, base/container includes
│ └── torrc.j2
└── vars/main.yml # application_id + lookup('config', ...) resolved vars
No onion vhost template, no domain lookup changes: onion domains fall out of DOMAIN_PRIMARY; the standard vhost serves them on :80 because TLS resolves disabled.
meta/services.yml¶
tor:
bond: 1
enabled: "{{ 'svc-net-tor' in group_names }}"
shared: "{{ 'svc-net-tor' in group_names }}"
build: true
image: svc-net-tor
version: "<pinned tor x.y.z>"
name: tor
backup:
no_stop_required: false # stop for consistent key-volume copy
lifecycle: alpha
# no public ports; SocksPort/ControlPort bind 127.0.0.1 only
torrc.j2¶
SocksPort 127.0.0.1:9050
ControlPort 127.0.0.1:9051
DataDirectory /var/lib/tor
HiddenServiceDir /var/lib/tor/infinito/node/
HiddenServiceVersion 3
HiddenServicePort 80 127.0.0.1:80
One HiddenServicePort covers all apps: every <sub>.<NODE_ONION>.onion request arrives on 127.0.0.1:80 where OpenResty routes by Host.
CLI mint helper¶
Location: under cli/administration/inventory/, integrated with credentials/{emit,vault}.py; runs automatically during inventory build when svc-net-tor is in the target groups.
If a vaulted key exists → reuse (idempotent, address stability is the contract).
Else: generate ed25519 seed → compute expanded secret key (SHA-512(seed), clamp) → derive address per the algorithm above → write the three Tor key files byte-exact.
Vault
hostname+ both key files; emitDOMAIN_PRIMARY: <address>.onioninto the generated inventory.Unit tests: known seed → known address test vector; idempotent reuse; file-format byte-exactness (headers + lengths).
tasks/01_restore_key.yml writes the vaulted files into the volume-backed HiddenServiceDir (owner = in-container tor UID, 0700 dir, 0600 secret) before the Tor container starts. Existing on-disk keys are never overwritten.
Per-domain TLS (extend tls.py / utils/tls_common)¶
Domain ends
.onion→enabled=false,protocols.web=http,ports.web=80, no ACME. OverridesTLS_MODEunconditionally.Non-onion domain → flavor from
applications.<app>.tls.mode, elseTLS_MODE.Foreign domains under
applications.<app>.server.domainsregister as separate HTTPS vhosts (existing per-domainsys-stk-front-proxycalls), same upstream as the onion vhost.
Config¶
DOMAIN_PRIMARY: "<node>.onion" # written by the mint helper
TOR_EGRESS_ENABLED: false # Decision #10
applications:
<app-id>:
server:
domains:
canonical: [ "app.example.org" ] # optional extra public domain
tls:
mode: letsencrypt # flavor for that app's non-onion domains
Acceptance Criteria¶
Scaffolding & lint¶
[ ]
roles/svc-net-tor/matches the layout;application_id: svc-net-tor;run_once_svc_net_torguard; all meta lint green (mapping, galaxy canon, layout, bond, README).[ ]
files/Dockerfilepinstor(no:latest); image builds in the stack.
Mint helper & key persistence¶
[ ] Helper mints offline, vaults
hostname+hs_ed25519_{public,secret}_key, emitsDOMAIN_PRIMARY; re-running reuses the key (address unchanged).[ ] Unit tests: known-vector address determinism, idempotent reuse, byte-exact file formats.
[ ]
01_restore_key.ymlrestores keys with strict perms before Tor starts; never overwrites existing keys; Tor serves exactly the minted address.[ ] Address survives redeploy, update, full-cycle reinstall; restoring the vault on a fresh box reproduces the same address; key volume covered by
svc-bkp-container-2-local.
Serving¶
[ ] Tor sidecar bootstraps (
network_mode: host); 9050/9051 bind127.0.0.1only; zero published ports.[ ]
lookup('tls', '<x>.onion')→ http/disabled/80, no ACME attempted; app vhosts serve onionserver_names on:80; every app reachable at<sub>.<NODE_ONION>.onionthroughHiddenServicePort 80 → 127.0.0.1:80.[ ] Onion requests reach upstreams with
X-Forwarded-Proto: https; no.onionrequest is 301’d to https; alias/www canonicalization stays onion-internal; CSP origins (CDN, Matomo, Keycloak, logout) resolve onion — no clearnet leak.
SSO / identity consistency¶
[ ] OIDC login completes end-to-end over
.onion: issuer, redirect URIs, web-origins all onion; no redirect-URI/issuer mismatch.[ ] oauth2-proxy
email_domainsmatches provisioned@<NODE_ONION>.onionusers;LDAP_DN_BASE+ CA identity resolve consistently.
Per-domain TLS¶
[ ] Extra domain under
applications.<app>.server.domains+tls.modedeploys as a separate HTTPS vhost (correct flavor) alongside the onion vhost, same upstream.[ ]
tls.pyunit tests: onion→forced off; foreign→app-level flavor; unset→TLS_MODE.
Egress¶
[ ]
TOR_EGRESS_ENABLEDdefaultsfalse(outbound untouched);trueexposes SOCKS to containers, documented.
CI / E2E¶
[ ] CI job: Tor bootstraps,
torsocks curl -I http://<sub>.<NODE_ONION>.onionreturns the app response; retried against transient Tor-network failures; unit tests independent of Tor-network availability.
Quality¶
[ ] Whole flow idempotent (second run: no changes); reloads handler-driven; clear failure if Tor cannot bootstrap.
[ ]
make testgreen tree-wide.[ ] README documents: model, mint helper usage, key backup/restore, limitations — no LE for
.onion; outbound public mail undeliverable from@onionsenders; fresh-node only (no clearnet migration); extra public domains = per-app opt-in.
Onion service extensions (phase 2, layered on a green onion core)¶
These build on the working onion node and are implemented after the web core is verified.
Mailu listening on the onion (operator: “if possible”)¶
The node hidden service exposes Mailu’s mail ports in addition to
80, via extraHiddenServicePortentries:HiddenServicePort 25,587,465,143,993→ Mailu’s local front binds on127.0.0.1. Mailu is then reachable at<node>.onionfor SMTP/submission/IMAP.Mechanism:
torrc.j2renders extra ports fromTOR_ONION_EXTRA_PORTS(list of{onion_port, target}), populated whenweb-app-mailuis deployed. Mailu’s front is bound to127.0.0.1in the onion node.[ ] With Mailu deployed on an onion node,
<node>.onion:993/:587accept connections over Tor; the extraHiddenServicePortlines are rendered only when Mailu is present.
Mail egress over Tor (operator: “must”)¶
Outbound mail to
.onionrecipient domains is routed through Tor’s SOCKS proxy (127.0.0.1:9050) instead of clearnet DNS/MX — enabling onion-to-onion / federated mail without public MX/rDNS.Mechanism: a Postfix transport for
.onionnext-hops via a SOCKS-aware delivery path (e.g. atorsocks-wrapped transport or a SOCKS relay), gated onsvc-net-torbeing active. Public-Internet mail to non-onion domains stays on the normal path.[ ] On an onion node, mail addressed to a
*.oniondomain is delivered via the Tor SOCKS proxy; delivery to non-onion domains is unaffected.
Playwright over Tor (operator: “probably”)¶
When the target under test is an
.oniondomain, the Playwright browser routes through Tor’s SOCKS proxy so specs exercise the real onion path (aligns with Decision #11’s real-Tor E2E).Mechanism: the
test-e2e-playwrightrunner sets the browser proxy tosocks5://127.0.0.1:9050(withsocks5remote DNS for.onionresolution) when the resolved domain ends in.onion.[ ] A Playwright spec against a
<sub>.<node>.onionsurface passes through the Tor SOCKS proxy on an onion node; clearnet targets are unaffected.
Out of scope¶
Dual clearnet+onion with Onion-Location bridge and mirrored SSO realms; per-app dedicated onions / Tor Client Authorization; distributed backups over onion; transparent full-egress torification (only .onion-destination mail egress is in phase 2); migration of existing clearnet nodes.
Validation¶
# mint runs during inventory build (sets DOMAIN_PRIMARY), then:
INFINITO_APPS="svc-net-tor,web-app-keycloak,web-app-nextcloud" make deploy-fresh-purged-apps INFINITO_FULL_CYCLE=true
torsocks curl -I http://next.cloud.<NODE_ONION>.onion # app over Tor, no public ports
# complete an SSO login over .onion; redeploy; confirm address unchanged
Prerequisites¶
Read AGENTS.md, then Role Loop and the Playwright contract. Verify key-file byte formats and vhost/TLS behavior in the DiD container first, then redeploy.
Implementation order¶
Mint helper + unit tests (address vector, formats, idempotency).
Role scaffold: Dockerfile, compose, torrc, key restore, volume + backup wiring.
tls.py/tls_common: onion branch + app-level flavor override + unit tests.E2E CI job (torsocks, retries).
README + cross-link the implementing PR here.
Commit Policy¶
No
git commituntil every Acceptance Criterion is checked.When green, operator runs
git-sign-pushoutside the sandbox; the agent MUST NOT push.
Context¶
https://spec.torproject.org/address-spec — v3 address + key formats
https://www.rfc-editor.org/rfc/rfc7686.html —
.onionspecial-use domainhttps://gist.github.com/mtigas/565718bbb928ce439e95 — subdomain hidden-service reference
https://www.w3.org/TR/secure-contexts/ —
.onionsecure contextroles/svc-ai-ollama/— archetypeplugins/lookup/tls.py— TLS resolutioncli/administration/inventory/— mint integration point