Architecture¶
One container, one Go binary as PID 1, N tor child processes.
torpool (PID 1)
├─ supervisor ──► tor ×N, each with its own SOCKS port,
│ control port and DataDirectory on container loopback
├─ SOCKS5 :9250 sticky, username = session key, password = token
├─ HTTP :9251 CONNECT + absolute-URI
└─ HTTP :8080 dashboard, REST, SSE, /metrics, /health
| Package | Responsibility |
|---|---|
internal/config |
Environment parsing, defaults, validation |
internal/auth |
The credential store, JWTs, tokens, scopes |
internal/tor |
torrc rendering, the control-port client, process supervision |
internal/pool |
Session pinning, instance selection, health, remediation |
internal/proxy |
The two client-facing listeners and the byte relay |
internal/stats |
Rolling time series and the audit log |
internal/server |
REST, SSE, metrics, embedded dashboard |
The layering is one-way: proxy and server depend on pool, pool depends on tor,
and tor knows nothing about any of them.
Why torpool is PID 1¶
It owns the Tor processes, so it has to reap them. Nothing else in the container will.
That also means shutdown is its job: docker stop sends SIGTERM to PID 1, and torpool
gives each child its own grace period so Tor can flush its state directory. Cutting that
short is what produces a corrupt DataDirectory on the next start.
Sessions and stickiness¶
A session key is the SOCKS5 username, or the Proxy-Authorization user over HTTP. The
password is the credential — see api.md — and a caller that
authenticates without naming a session gets its key from DEFAULT_SESSION.
The two halves are independent, which is what makes AUTH_DISABLED a change to
authentication only. With it set the password is ignored, but the username is still read:
the listeners keep preferring username/password over SOCKS5's "no authentication" whenever
a client offers both, precisely so a caller does not silently lose its stickiness along with
its credential.
The key is still an identity hint, not a boundary. Authentication decides who may use the pool at all; it does not partition it. Any valid token may claim any key, and many callers presenting the same key deliberately share an instance. Sessions separate exit identities, not tenants.
New sessions go to the instance with the fewest pinned sessions, with a random tie-break — a deterministic tie-break would funnel every new session onto the same instance until its count rose.
Instances that are mid-rotation are skipped: one that has just closed its circuits has none to offer for a second or two, and a caller pinned to it there and then would wait on a rotation it never asked for. They stay candidates of last resort, so a single-instance pool still routes.
Credentials are not forwarded to Tor. Tor's IsolateSOCKSAuth is on by default, so
passing the key through would give each session its own circuit inside one instance.
Two callers pinned to the same instance would then see different exit IPs, and "an
instance is an exit identity" — the model the whole pool rests on — would be false.
Knowing the exit IP¶
Resolved entirely from Tor's own view, at no Tor-bandwidth cost:
circuit-status → the circuit carrying a live stream → its last hop
→ ns/id/<fingerprint> → the relay's address
→ ip-to-country/<address>
The answer must be stable, not merely current, because "an instance is an exit identity" is only true if the reported exit holds still. Tor keeps several built circuits at once and keeps building more preemptively, so the choice is made in this order:
- the circuit carrying a stream — the only one Tor has committed traffic to;
- the exit reported last time, while a circuit to it still stands;
- the newest circuit, by its
TIME_CREATED, when there is nothing better to go on.
Naming an exit that no traffic has ever used is what the first implementation did, and it reported the wrong IP; following Tor around its preemptive circuits is what made one instance appear to alternate between two exits.
Step 1 is also why PURPOSE=CONFLUX_LINKED circuits count as exit-bearing alongside
GENERAL ones. Conflux is on by default in current Tor, and its linked legs — which share
an exit by construction — are what streams actually ride; a resolver that accepts only
GENERAL reads the preemptive circuits and never the ones in use.
Circuits older than the instance's last NEWNYM are excluded outright: that signal makes
every existing circuit unusable for new streams, so their exits are no longer where the
instance goes out. For the couple of seconds before Tor has rebuilt, the API reports no
exit at all and hands the discarded one over separately as retired_exit_ip, so the
dashboard can show what it was without claiming it is live.
Retiring circuits on rotation¶
NEWNYM alone does not finish a rotation. It marks the existing circuits unusable for new
streams but leaves them standing, and while they stand Tor sees no shortage of circuits and
builds no replacement — an idle instance was measured sitting for minutes with nothing to
report, while traffic was still observed leaving through a retired conflux set. Rotation
therefore closes every exit-bearing circuit built before the NEWNYM; Tor rebuilds at once
and the next request goes out through the new exit.
Circuits carrying a stream are spared, because a proxy connection is pinned to its instance for its whole life and closing one would fail a request already in flight.
Because a stream only exists during a request, the exit is sampled shortly after a connection is established, debounced per instance, plus a slow background refresh.
Failure accounting¶
Per instance, over a sliding window, from two sources:
- transport — refused SOCKS handshakes, resets, timeouts. Free, and blind to HTTP-level blocking.
- client —
POST /api/sessions/{key}/failure. The only signal that catches soft blocks, because the balancer cannot see inside an HTTPS tunnel.
Quarantine triggers on consecutive failures (a hard-dead instance drops out fast) or on a windowed score (an instance failing half its requests never accumulates consecutive failures but is just as unusable). A success resets the consecutive count but not the window — an instance failing every other request is still unhealthy.
A score rather than a count, because failures are not equally damning and the source does not say which is which. What a client reports — the kind — does:
| Kind | Says about the exit | Weight |
|---|---|---|
captcha |
Burnt. A challenge is rarely path-specific | Several reports' worth — at the default threshold, two are enough |
blocked |
Unwelcome, not broken. Retrying never fixes it | Heavy, below a captcha: a 403 can be about the path |
transport, other |
It failed; nothing more is known | The baseline |
rate_limited |
It works, and is being asked for too much | Less than one report, and it neither trips the consecutive count nor spends a probation |
QUARANTINE_FAILURES still counts whole baseline failures, so a caller that reports
nothing but untyped failures sees the behaviour it always did. The weights live with the
kinds in internal/pool/health.go; failure_score and quarantine_score in the API are
what they add up to.
Two things bound what the weights can do. A single report never quarantines a healthy
instance, however heavy — a caller can misread one page — unless
QUARANTINE_FAILURES is 1, which is asking for exactly that. And the consecutive
trigger is blind to kind, so a caller failing repeatedly with no success in between
reaches QUARANTINE_CONSECUTIVE first whatever it reports; weighing only decides the
outcome for a caller still getting work done between failures. That is also why keeping
rate limits out of the consecutive count matters more than their weight does.
The rate-limit carve-out is the point of typing them at all. A 429 follows the traffic,
not the IP, so rotating away from one spends a working exit and arrives at the next one
still throttled — while a captcha is the clearest evidence available that the exit itself
is spent, and waiting for QUARANTINE_FAILURES of those means every report after the
second is another request answered with a challenge.
The remediation ladder¶
Escalation is driven by recurrence inside ESCALATION_WINDOW, not by attempt count.
An instance that misbehaved once weeks ago starts again at the cheapest rung; one
failing repeatedly has proven the cheap fix does not work.
A remediated instance returns on probation, where a single failure re-quarantines it immediately — the cheap fix has demonstrably failed, so there is nothing to wait for. Surviving a request clears probation.
In a single-container topology the third rung is the strongest action available; there is no separate container to recreate. The ladder is honestly three rungs, not four.
Separately, a watchdog restarts Tor processes that die on their own. The failure ladder would never notice: a dead instance takes no traffic and so records no failures.
State that is not persisted¶
Time series and the audit log live in memory and reset on restart. Session pinnings do
too — after a restart, callers are simply reassigned. Tor's DataDirectory is the only
thing on the volume, and only so restarts re-bootstrap from a cached consensus.
Adding persistence later is contained behind internal/stats.
Invariants¶
The subtle ones are listed in AGENTS.md. The two that bite hardest:
- A proxy connection is pinned to its instance for its entire life, so rotation only affects new connections. Clients must drop pooled keep-alives after rotating.
- Listeners bind
0.0.0.0inside the container — a container-loopback bind is unreachable through a port mapping. Exposure is the host-side publish's job.