TorLab is a containerized Tor testing environment built with:
- Docker
- Containerlab
- Alpine Linux
- Single unified Tor image (role-based via
ROLEenv)
It is designed for:
- Tor architecture testing
- Directory Authority experiments
- Exit relay behavior analysis
- Traffic correlation research (client ↔ server observation)
br_core (10.10.20.0/24)
┌─────────────────────────────────────────────┐
│ │
│ Clients Relays Exit Nodes DA │
│ │
└─────────────────────────────────────────────┘
│
│ (exit second NIC)
▼
br_servers (10.10.30.0/24)
│
Servers
- Single Docker image:
localhost/torlab - Role-based configuration (
ROLE=client|relay|exit|da|server) - DA identity persisted via volume
- Relay and exit node identity persisted via volumes to avoid fingerprint mismatch on redeploy
- Tor configs rendered dynamically from templates
- Use container hostname as tor
Nicknamefor easy log tracking - No routing complexity
- No BGP
- No ISP simulation
- Clean dual-vantage traffic correlation
| ROLE | Description | Lab node hostname |
|---|---|---|
| da | Directory Authority | clab-torlab-da* |
| relay | Middle relay | clab-torlab-r* |
| exit | Exit relay (dual-homed) | clab-torlab-x* |
| client | Tor client | clab-torlab-c* |
| server | HTTP test server | clab-torlab-s* |
From repository root:
sudo docker build -t localhost/torlab ./images/torAll nodes that act as Tor relays or authorities persist their /var/lib/tor via bind mounts.
This ensures Tor identity keys survive redeploys and clients do not encounter fingerprint mismatches.
Create all required directories in one step:
for n in da1 r1 r2 r3 r4 r5 x1 x2 x3; do
sudo mkdir -p containerlab/tor-data/$n
sudo chown -R 100:100 containerlab/tor-data/$n
sudo chmod -R u+rwX,g+rwX containerlab/tor-data/$n
doneRun ephemeral key generator container:
sudo docker run --rm \
--user root \
--entrypoint /srv/generate-da-keys.sh \
-v "$(realpath containerlab/tor-data/da1)":/var/lib/tor:Z \
localhost/torlab \
da1 10.10.20.40 9001 9030This creates:
tor-data/da1/
├── keys/
│ ├── authority_identity_key
│ ├── authority_signing_key
│ └── authority_certificate
├── fingerprint
└── fingerprints.txt
Extract fingerprints:
sudo cat containerlab/tor-data/da1/fingerprints.txtCopy:
DA_FP— RSA relay fingerprintDA_V3IDENT— V3 authority identity
into containerlab/torlab.clab.yml.
Note: DA keys are generated once. Relay keys (r1-r5, x1-x3) are generated automatically on first start and persisted in their respective
tor-data/directories. Do not delete these directories between deploys or clients will fail withUnexpected identity in router certificateerrors.
sudo ip link add br_core type bridge
sudo ip link set br_core up
sudo ip link add br_servers type bridge
sudo ip link set br_servers up
ip -br link show | grep -E 'br_core|br_servers'sudo firewall-cmd --zone=trusted --add-interface=br_core
sudo firewall-cmd --zone=trusted --add-interface=br_servers
sudo firewall-cmd --zone=public --add-port 9501/tcp
sudo firewall-cmd --zone=public --add-port 9502/tcp
sudo firewall-cmd --zone=public --add-port 9503/tcp
sudo containerlab deploy -t containerlab/torlab.clab.ymlCheck status:
sudo containerlab inspect -t containerlab/torlab.clab.ymlThe three client nodes export their SOCKS5 proxy port to the host machine. You can point a browser or tool directly to one of these ports to route traffic through the lab Tor network:
| Client | Host SOCKS5 Port |
|---|---|
| c1 | 9501 |
| c2 | 9502 |
| c3 | 9503 |
Configure your browser to use SOCKS5 proxy:
- Host:
containerlab host ip address - Port:
9501(or 9502 / 9503) - Enable "Proxy DNS when using SOCKS5" if available
Example with curl (loclahost):
curl --socks5-hostname 127.0.0.1:9501 http://10.10.30.11:8081
curl --socks5-hostname 127.0.0.1:9502 http://10.10.30.12:8082
curl --socks5-hostname 127.0.0.1:9503 http://10.10.30.13:8083Note: Traffic routed this way exits through one of the lab exit nodes (x1/x2/x3) and reaches the lab servers (s1/s2/s3). No traffic leaves the lab environment.
Exit node traffic is controlled via the exit node torrc template (torrc-exit.tmpl).
Only the following ports are permitted by default:
# Allow only those ports in lab
ExitPolicy accept *:8080
ExitPolicy accept *:8081
ExitPolicy accept *:8082
ExitPolicy accept *:8083
ExitPolicy accept *:80
ExitPolicy accept *:443
ExitPolicy reject *:*
To allow additional ports, edit images/tor/torrc-exit.tmpl and rebuild the image:
sudo docker build -t localhost/torlab ./images/torThen redeploy the lab:
sudo containerlab destroy -t containerlab/torlab.clab.yml
sudo containerlab deploy -t containerlab/torlab.clab.ymlNote: After deploy, wait 2-5 minutes for the DA to complete its first voting cycle and publish a consensus. Relays need additional time to register and appear in the consensus.
Validate RSA fingerprint matches DA_FP in torlab.clab.yml:
sudo docker exec clab-torlab-da1 cat /var/lib/tor/fingerprintValidate V3 identity matches DA_V3IDENT in torlab.clab.yml:
sudo docker exec clab-torlab-da1 grep "fingerprint" /var/lib/tor/keys/authority_certificateCheck DA logs for successful voting and consensus publishing:
sudo docker logs clab-torlab-da1 | grep -E "vote|consensus|Published" | tail -10Expected output includes:
Time to vote.
Retrieved da1's vote from self.
Vote posted.
Published ns consensus
Published microdesc consensus
List all nodes in the current consensus:
sudo docker exec clab-torlab-da1 grep "^r " /var/lib/tor/cached-consensusExpected: 9 nodes visible with their 10.10.20.x addresses (da1, r1-r5, x1-x3).
Check node flags — exit nodes must have the Exit flag:
sudo docker exec clab-torlab-da1 grep -A1 "^r " /var/lib/tor/cached-consensusExpected for exit nodes: s Exit Fast Guard HSDir Running Stable V2Dir Valid
Verify relay torrc has correct Address:
sudo docker exec clab-torlab-r1 cat /tmp/torrc-relay.confAddress must be 10.10.20.31.
Check relay published its descriptor:
sudo docker logs clab-torlab-r1 | grep -i "publishing\|reachable" | tail -5Expected:
Self-testing indicates your ORPort 10.10.20.31:9001 is reachable from the outside. Excellent. Publishing server descriptor.
Verify exit torrc has correct Address, ExitRelay and ExitPolicy:
sudo docker exec clab-torlab-x1 cat /tmp/torrc-exit.confCheck exit published its descriptor:
sudo docker logs clab-torlab-x1 | grep -i "publishing\|reachable" | tail -5sudo docker logs clab-torlab-c1 | grep -i "bootstrap" | tail -10Expected last line:
Bootstrapped 100% (done): Done
Check client has full consensus with all nodes:
sudo docker exec clab-torlab-c1 grep "^r " /var/lib/tor/cached-microdesc-consensusAll nodes have ControlPort 9051 enabled with no authentication (CookieAuthentication 0).
Check active circuits on a client:
sudo docker exec clab-torlab-c1 sh -c 'echo -e "AUTHENTICATE\r\nGETINFO circuit-status\r\nQUIT" | nc 127.0.0.1 9051'Trigger a request first to build a full circuit, then inspect:
sudo docker exec clab-torlab-c1 curl --socks5-hostname 127.0.0.1:9050 http://10.10.30.11:8081 &
sleep 10
sudo docker exec clab-torlab-c1 sh -c 'echo -e "AUTHENTICATE\r\nGETINFO circuit-status\r\nQUIT" | nc 127.0.0.1 9051'Expected output shows a BUILT circuit with 3 hops (guard → middle → exit):
250-circuit-status=5 BUILT $FINGERPRINT1~node1,$FINGERPRINT2~node2,$FINGERPRINT3~node3 PURPOSE=GENERAL
Check streams (active connections) on a client:
sudo docker exec clab-torlab-c1 sh -c 'echo -e "AUTHENTICATE\r\nGETINFO stream-status\r\nQUIT" | nc 127.0.0.1 9051'Check active circuits on a relay:
sudo docker exec clab-torlab-r1 sh -c 'echo -e "AUTHENTICATE\r\nGETINFO circuit-status\r\nQUIT" | nc 127.0.0.1 9051'After building a circuit, map the fingerprints to node IPs.
Get active circuits:
sudo docker exec clab-torlab-c1 sh -c 'echo -e "AUTHENTICATE\r\nGETINFO circuit-status\r\nQUIT" | nc 127.0.0.1 9051'Each BUILT circuit shows 3 hops in order: guard → middle → exit:
BUILT $FINGERPRINT1~Unnamed,$FINGERPRINT2~Unnamed,$FINGERPRINT3~Unnamed PURPOSE=GENERAL
Map fingerprints to IPs using the consensus:
sudo docker exec clab-torlab-da1 grep "^r " /var/lib/tor/cached-consensusThe consensus format is:
r <nickname> <identity_b64> <digest_b64> <date> <IP> <ORPort> <DirPort>
The fingerprint in circuit-status is hex. To convert the base64 identity from consensus to hex for matching:
echo "<base64_identity>" | base64 -d | xxd -p | tr -d '\n' | tr '[:lower:]' '[:upper:]'Example full trace:
# 1. Get circuit
sudo docker exec clab-torlab-c1 sh -c 'echo -e "AUTHENTICATE\r\nGETINFO circuit-status\r\nQUIT" | nc 127.0.0.1 9051' | grep BUILT | head -1
# 2. Get consensus node list
sudo docker exec clab-torlab-da1 grep "^r " /var/lib/tor/cached-consensus
# 3. Convert identity to hex for each hop and match to IP
echo "<base64_from_consensus>" | base64 -d | xxd -p | tr -d '\n' | tr '[:lower:]' '[:upper:]'Note: Tor 0.4.9 uses Conflux — a multipath feature that builds multiple parallel circuits sharing the same
CONFLUX_ID. This is normal and expected. Each group of circuits with the sameCONFLUX_IDrepresents one logical connection split across multiple paths for performance.
Verify exit node can reach server directly:
sudo docker exec clab-torlab-x1 curl http://10.10.30.11:8081Test full Tor circuit from client to server:
sudo docker exec clab-torlab-c1 sh -c 'curl --socks5-hostname 127.0.0.1:9050 -H "X-Client-ID: ${HOSTNAME}" http://10.10.30.11:8081'
sudo docker exec clab-torlab-c1 sh -c 'curl --socks5-hostname 127.0.0.1:9050 -H "X-Client-ID: ${HOSTNAME}" http://10.10.30.12:8082'
sudo docker exec clab-torlab-c1 sh -c 'curl --socks5-hostname 127.0.0.1:9050 -H "X-Client-ID: ${HOSTNAME}" http://10.10.30.13:8083'Expected: HTTP response OK from the test server.
Check server logs
docker exec clab-torlab-s1 cat /tmp/server.logsudo containerlab inspect -t containerlab/torlab.clab.ymlAll nodes must show running state. da1 must not be in restarting.
Observation points:
sudo docker exec -it clab-torlab-c1 tcpdump -i eth1 -w client.pcapsudo docker exec -it clab-torlab-s1 tcpdump -i eth1 -w server.pcapGoal:
Determine which client communicates with which server based on:
- burst timing
- packet direction
- TLS record size
- throughput patterns
Destroy lab:
sudo containerlab destroy -t containerlab/torlab.clab.yml --cleanupClean up Docker networks (this removes all currently unused networks):
sudo docker network pruneRemove DA keys (optional, destroys all node identities):
rm -rf containerlab/tor-data/Remove only relay/exit keys (preserves DA keys):
for n in r1 r2 r3 r4 r5 x1 x2 x3; do
rm -rf containerlab/tor-data/$n
doneManual remove clab links:
for iface in $(ip link show | grep clab | awk -F': ' '{print $2}' | awk -F'@' '{print $1}'); do
sudo ip link delete "$iface" 2>/dev/null || true
done- This environment is for testing only.
TestingTorNetwork 1is enabled.- Not connected to the public Tor network.
- No IPv6.
- No NAT required.
- Exit nodes allow HTTP (port 80), HTTPS (port 443), and lab HTTP (port 8080,8081,8082,8083).
- All nodes expose
ControlPort 9051with no authentication for inspection. - Relay and exit node keys are persisted in
containerlab/tor-data/— do not delete between deploys unless you want to regenerate all identities.
Source code in this repository is licensed under the Apache License 2.0.
Documentation, diagrams, screenshots, examples and article text are licensed under Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International (CC BY-NC-ND 4.0), unless otherwise stated.
Copyright (c) 2026 Nikolay Dachev.