Implement Tor transport with operator visibility

Add TorTransport in src/transport/tor/ supporting three operating modes:

Outbound (socks5 mode):
- Non-blocking SOCKS5 connect via tokio-socks with per-destination
  circuit isolation (IsolateSOCKSAuth)
- TorAddr enum for .onion and clearnet address types
- Connection pool with per-connection receive tasks, reuses TCP
  stream FMP framing
- connect_async()/connection_state_sync()/promote_connection() follow
  the same non-blocking polling pattern as TCP transport

Inbound (directory mode — recommended for production):
- Tor manages the onion service via HiddenServiceDir in torrc
- FIPS reads .onion address from hostname file at startup
- No control port needed — enables Tor Sandbox 1 (seccomp-bpf)
- Accept loop mirrors TCP pattern with DirectoryServiceConfig

Monitoring (control_port mode and optional in directory mode):
- Async control port client supporting TCP and Unix socket connections
  via Box<dyn AsyncRead/Write> trait objects
- AUTHENTICATE with cookie or password auth
- 8 GETINFO queries: bootstrap, circuits, traffic, liveness, version,
  dormant state, SOCKS listeners
- Background monitoring task polls every 10s, caches TorMonitoringInfo
  in Arc<RwLock> for synchronous query access
- Bootstrap milestone logging (25/50/75/100%), stall warning (>60s),
  network liveness transitions, dormant mode entry
- Directory mode optionally connects to control port when control_addr
  is configured (non-fatal on failure)

Operator visibility:
- show_transports query exposes tor_mode, onion_address, tor_monitoring
  (bootstrap, circuit_established, traffic, liveness, version, dormant)
- fipstop transport detail view: Tor mode, onion address, SOCKS5/control
  errors, connection stats, Tor daemon status section
- fipstop table view: tor(mode) label with truncated onion address hint

Security hardening:
- Per-destination circuit isolation via IsolateSOCKSAuth
- Unix socket default for control port (/run/tor/control)
- Reference torrc with HiddenServiceDir, VanguardsLiteEnabled,
  ConnectionPadding, DoS protections (PoW + intro rate limiting)

Config:
- TorConfig with socks5, control_port, and directory modes
- DirectoryServiceConfig: hostname_file, bind_addr
- control_addr, control_auth, cookie_path, connect_timeout,
  max_inbound_connections

Testing:
- 69 unit + integration tests with mock SOCKS5 and control servers
- Docker tests: socks5-outbound (clearnet via Tor) and directory-mode
  (HiddenServiceDir onion service)

Documentation:
- Transport layer design doc: Tor architecture, directory mode
- Configuration doc: Tor config tables and examples
This commit is contained in:
Johnathan Corgan
2026-03-15 16:19:54 +00:00
parent c0f30d8fe8
commit 6c90cf6c02
40 changed files with 4842 additions and 29 deletions
+8
View File
@@ -0,0 +1,8 @@
# Release binaries (copied at test time)
**/fips
**/fipsctl
**/fipstop
# Generated configs (created per-run from templates)
*/configs/node-*.yaml
!*/configs/node-*.yaml.tmpl
+31
View File
@@ -0,0 +1,31 @@
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
iproute2 iputils-ping dnsutils openssh-client openssh-server iperf3 \
dnsmasq curl python3 rsync && \
rm -rf /var/lib/apt/lists/*
# Setup SSH server with no authentication (test only!)
RUN mkdir -p /var/run/sshd && \
ssh-keygen -A && \
sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \
sed -i 's/#PermitEmptyPasswords no/PermitEmptyPasswords yes/' /etc/ssh/sshd_config && \
sed -i 's/UsePAM yes/UsePAM no/' /etc/ssh/sshd_config && \
passwd -d root
# dnsmasq: forward .fips to FIPS daemon, everything else to Docker DNS
RUN printf '%s\n' \
'port=53' \
'listen-address=127.0.0.1' \
'bind-interfaces' \
'server=/fips/127.0.0.1#5354' \
'server=127.0.0.11' \
'no-resolv' \
>> /etc/dnsmasq.conf
COPY fips fipsctl /usr/local/bin/
RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl
# Start dnsmasq, SSH server, and run FIPS
ENTRYPOINT ["/bin/bash", "-c", "dnsmasq && /usr/sbin/sshd && exec fips --config /etc/fips/fips.yaml"]
+1
View File
@@ -0,0 +1 @@
nameserver 127.0.0.1
+21
View File
@@ -0,0 +1,21 @@
# Tor configuration for FIPS socks5-outbound integration testing.
# Provides a SOCKS5 proxy on port 9050 accessible from the Docker network.
# Bind to all interfaces (Docker bridge network requires non-localhost)
# IsolateSOCKSAuth is required because FIPS uses SOCKS5 username/password
# for per-destination circuit isolation. Without it, Tor rejects auth method 0x02.
SocksPort 0.0.0.0:9050 IsolateSOCKSAuth
# Security hardening
ExitRelay 0
ExitPolicy reject *:*
VanguardsLiteEnabled 1
ConnectionPadding 1
SafeLogging 1
# Reduce startup time by not waiting for full circuit build
# (we're testing SOCKS5 connectivity, not anonymity properties)
__DisablePredictedCircuits 1
# Logging
Log notice stderr
@@ -0,0 +1,37 @@
# Dockerfile for directory-mode test: Tor + FIPS co-located in one container.
#
# Tor manages the onion service via HiddenServiceDir. FIPS reads the
# .onion hostname from /var/lib/tor/fips_onion_service/hostname at startup.
# This requires Tor to bootstrap and create the hostname file before FIPS
# starts, so the entrypoint script waits for it.
FROM debian:bookworm-slim
ARG TORRC=torrc
RUN apt-get update && \
apt-get install -y --no-install-recommends \
tor iproute2 iputils-ping dnsutils \
dnsmasq python3 && \
rm -rf /var/lib/apt/lists/*
# dnsmasq: forward .fips to FIPS daemon, everything else to Docker DNS
RUN printf '%s\n' \
'port=53' \
'listen-address=127.0.0.1' \
'bind-interfaces' \
'server=/fips/127.0.0.1#5354' \
'server=127.0.0.11' \
'no-resolv' \
>> /etc/dnsmasq.conf
COPY fips fipsctl /usr/local/bin/
RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl
COPY ${TORRC} /etc/tor/torrc
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY resolv.conf /etc/resolv.conf
ENTRYPOINT ["/entrypoint.sh"]
@@ -0,0 +1,26 @@
# FIPS directory-mode test — node A (onion service via HiddenServiceDir)
#
# Runs in directory mode: Tor manages the onion service via HiddenServiceDir
# in torrc. FIPS reads the .onion address from the hostname file.
# No control port needed. Enables Tor Sandbox 1.
node:
identity:
nsec: "{{NSEC_A}}"
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: "127.0.0.1"
transports:
tor:
mode: "directory"
socks5_addr: "127.0.0.1:9050"
directory_service:
hostname_file: "/var/lib/tor/fips_onion_service/hostname"
bind_addr: "127.0.0.1:8443"
@@ -0,0 +1,30 @@
# FIPS directory-mode test — node B (outbound connector)
#
# Runs in socks5 mode: connects outbound to node A's .onion address
# via the shared Tor daemon's SOCKS5 proxy. The .onion address and
# npub are injected by the test script at runtime.
node:
identity:
nsec: "{{NSEC_B}}"
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: "127.0.0.1"
transports:
tor:
socks5_addr: "127.0.0.1:9050"
peers:
- npub: "{{NPUB_A}}"
alias: "dir-a"
addresses:
- transport: tor
addr: "{{ONION_ADDR_A}}"
connect_policy: auto_connect
@@ -0,0 +1,56 @@
# Tor transport integration test — directory mode
#
# Topology:
# [fips-a] — Tor + FIPS co-located, HiddenServiceDir onion service
# [fips-b] — Tor + FIPS co-located, socks5-only, connects to fips-a's .onion
#
# Both nodes run Tor and FIPS in the same container. Node A's Tor manages
# the onion service via HiddenServiceDir with Sandbox 1. Node B connects
# outbound through its local Tor's SOCKS5 proxy.
networks:
dir-test:
driver: bridge
services:
fips-a:
build:
context: .
dockerfile: Dockerfile.colocated
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
restart: "no"
container_name: fips-dir-a
hostname: fips-dir-a
volumes:
- ./configs/node-a.yaml:/etc/fips/fips.yaml:ro
environment:
- RUST_LOG=info,fips::transport::tor=debug
networks:
dir-test:
fips-b:
build:
context: .
dockerfile: Dockerfile.colocated
args:
TORRC: torrc.socks5
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
restart: "no"
container_name: fips-dir-b
hostname: fips-dir-b
volumes:
- ./configs/node-b.yaml:/etc/fips/fips.yaml:ro
environment:
- RUST_LOG=info,fips::transport::tor=debug
networks:
dir-test:
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Entrypoint for directory-mode test container.
# Starts Tor, optionally waits for hostname file, then starts FIPS.
set -e
echo "Starting dnsmasq..."
dnsmasq
# Check if this node uses directory mode (match the YAML value, not comments)
IS_DIRECTORY_MODE=false
if grep -qE '^\s+mode:\s+"directory"' /etc/fips/fips.yaml 2>/dev/null; then
IS_DIRECTORY_MODE=true
fi
# Pre-create HiddenServiceDir with correct permissions.
# Tor requires 0700 on the directory.
HIDDEN_SERVICE_DIR="/var/lib/tor/fips_onion_service"
if [ "$IS_DIRECTORY_MODE" = true ]; then
mkdir -p "$HIDDEN_SERVICE_DIR"
chmod 700 "$HIDDEN_SERVICE_DIR"
fi
echo "Starting Tor daemon..."
tor -f /etc/tor/torrc &
# If this node uses directory mode, wait for Tor to create the hostname file
if [ "$IS_DIRECTORY_MODE" = true ]; then
HOSTNAME_FILE="${HIDDEN_SERVICE_DIR}/hostname"
echo "Waiting for Tor to create ${HOSTNAME_FILE}..."
for i in $(seq 1 120); do
if [ -f "$HOSTNAME_FILE" ]; then
echo "Tor hostname file ready after ${i}s: $(cat "$HOSTNAME_FILE")"
break
fi
sleep 1
done
if [ ! -f "$HOSTNAME_FILE" ]; then
echo "FATAL: Tor did not create hostname file within 120s"
exit 1
fi
fi
echo "Starting FIPS daemon..."
exec fips --config /etc/fips/fips.yaml
+1
View File
@@ -0,0 +1 @@
nameserver 127.0.0.1
+260
View File
@@ -0,0 +1,260 @@
#!/bin/bash
# Tor directory-mode integration test.
#
# Validates end-to-end connectivity through a Tor onion service managed
# by HiddenServiceDir (directory mode) with Sandbox 1:
# fips-a creates onion service via Tor-managed HiddenServiceDir
# fips-b connects outbound to fips-a's .onion address via SOCKS5
#
# Both containers run Tor + FIPS co-located. This is the recommended
# production deployment mode.
#
# Requires internet — the Tor daemon must bootstrap to the network
# and publish the onion service descriptor for .onion routing.
#
# Usage: ./directory-test.sh
set -e
trap 'echo ""; echo "Test interrupted — cleaning up..."; docker compose down 2>/dev/null; exit 130' INT
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEST_DIR="$SCRIPT_DIR/.."
DERIVE_KEYS="$SCRIPT_DIR/../../../static/scripts/derive-keys.py"
cd "$TEST_DIR"
PASSED=0
FAILED=0
TIMEOUT_PING=15
MAX_WAIT_ONION=120
MAX_WAIT_PEER=180
# Count connected peers for a node using fipsctl show peers JSON output
count_connected_peers() {
local container="$1"
docker exec "$container" fipsctl show peers 2>/dev/null \
| python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
print(sum(1 for p in data.get('peers', []) if p.get('connectivity') == 'connected'))
except:
print(0)
" 2>/dev/null || echo 0
}
echo "=== FIPS Tor Directory-Mode Integration Test ==="
echo ""
# ── Phase 0: Setup ───────────────────────────────────────────────
echo "Phase 0: Setup..."
# Copy binaries from common (built externally)
if [ ! -f fips ] || [ ! -f fipsctl ]; then
if [ -f ../common/fips ] && [ -f ../common/fipsctl ]; then
cp ../common/fips ../common/fipsctl .
echo " Copied binaries from ../common/"
else
echo " ERROR: fips and fipsctl binaries not found."
echo " Build them first: cargo build --release"
echo " Then copy to testing/tor/common/ or testing/tor/directory-mode/"
exit 1
fi
fi
# Generate ephemeral identities
MESH_NAME="dir-test-$(date +%s)-$$"
echo " Mesh name: $MESH_NAME"
KEYS_A=$(python3 "$DERIVE_KEYS" "$MESH_NAME" "a")
NSEC_A=$(echo "$KEYS_A" | grep "^nsec=" | cut -d= -f2)
NPUB_A=$(echo "$KEYS_A" | grep "^npub=" | cut -d= -f2)
KEYS_B=$(python3 "$DERIVE_KEYS" "$MESH_NAME" "b")
NSEC_B=$(echo "$KEYS_B" | grep "^nsec=" | cut -d= -f2)
NPUB_B=$(echo "$KEYS_B" | grep "^npub=" | cut -d= -f2)
echo " Node A: $NPUB_A"
echo " Node B: $NPUB_B"
# Generate node-a config
sed "s/{{NSEC_A}}/$NSEC_A/" configs/node-a.yaml.tmpl > configs/node-a.yaml
echo " Node A config generated"
echo ""
# ── Phase 1: Start node A (Tor + FIPS co-located) ────────────────
echo "Phase 1: Starting node A (Tor+FIPS, directory-mode onion service)..."
docker compose down 2>/dev/null || true
docker compose up -d --build fips-a
echo ""
# ── Phase 2: Wait for onion service creation ─────────────────────
echo "Phase 2: Waiting for node A's onion service (up to ${MAX_WAIT_ONION}s)..."
echo " (Tor bootstrap + HiddenServiceDir publication)"
ONION_ADDR=""
elapsed=0
while [ "$elapsed" -lt "$MAX_WAIT_ONION" ]; do
# Extract .onion address from structured log: onion_address=<addr>.onion
# Strip ANSI color codes before matching (tracing emits them by default)
ONION_ADDR=$(docker logs fips-dir-a 2>&1 \
| sed 's/\x1b\[[0-9;]*m//g' \
| grep -oE 'onion_address=[a-z2-7]{56}\.onion' \
| head -1 \
| cut -d= -f2)
if [ -n "$ONION_ADDR" ]; then
echo " Onion service active after ${elapsed}s"
echo " Address: $ONION_ADDR"
break
fi
sleep 5
elapsed=$((elapsed + 5))
echo " ${elapsed}s..."
done
if [ -z "$ONION_ADDR" ]; then
echo " FAIL: Onion service not created within ${MAX_WAIT_ONION}s"
echo ""
echo "Node A logs (last 30 lines):"
docker logs fips-dir-a 2>&1 | tail -30
docker compose down
exit 1
fi
echo ""
# ── Phase 3: Start node B with .onion address ────────────────────
echo "Phase 3: Starting node B (Tor+FIPS, socks5-only)..."
# Generate node-b config with discovered .onion address + virtual port
ONION_CONNECT="${ONION_ADDR}:8443"
sed -e "s/{{NSEC_B}}/$NSEC_B/" \
-e "s/{{NPUB_A}}/$NPUB_A/" \
-e "s/{{ONION_ADDR_A}}/$ONION_CONNECT/" \
configs/node-b.yaml.tmpl > configs/node-b.yaml
echo " Node B config generated (target: $ONION_CONNECT)"
docker compose up -d fips-b
echo ""
# ── Phase 4: Wait for peer connection ────────────────────────────
echo "Phase 4: Waiting for peer connection (up to ${MAX_WAIT_PEER}s)..."
echo " (SOCKS5 circuit setup + .onion routing may take a while)"
peers_a=0
peers_b=0
elapsed=0
while [ "$elapsed" -lt "$MAX_WAIT_PEER" ]; do
peers_a=$(count_connected_peers fips-dir-a)
peers_b=$(count_connected_peers fips-dir-b)
if [ "$peers_a" -ge 1 ] && [ "$peers_b" -ge 1 ]; then
echo " Both nodes connected after ${elapsed}s (A: ${peers_a}, B: ${peers_b})"
break
fi
sleep 10
elapsed=$((elapsed + 10))
echo " ${elapsed}s... (A peers: ${peers_a}, B peers: ${peers_b})"
done
if [ "$peers_a" -lt 1 ] || [ "$peers_b" -lt 1 ]; then
echo " FAIL: Peers not established within ${MAX_WAIT_PEER}s"
echo ""
echo "Node A logs (last 30 lines):"
docker logs fips-dir-a 2>&1 | tail -30
echo ""
echo "Node B logs (last 30 lines):"
docker logs fips-dir-b 2>&1 | tail -30
docker compose down
exit 1
fi
# Extra convergence time for routing
echo " Waiting 10s for routing convergence..."
sleep 10
echo ""
# ── Phase 5: Connectivity tests ──────────────────────────────────
echo "Phase 5: Connectivity tests"
PING_COUNT=11
ping_series() {
local from="$1"
local to_npub="$2"
local label="$3"
echo " $label ($PING_COUNT pings, dropping first):"
local rtts=()
local fails=0
for i in $(seq 1 "$PING_COUNT"); do
local output
if output=$(docker exec "$from" ping6 -c 1 -W "$TIMEOUT_PING" "${to_npub}.fips" 2>&1); then
local rtt
rtt=$(echo "$output" | grep -oE 'time=[0-9.]+' | cut -d= -f2)
if [ -n "$rtt" ]; then
printf " %2d: %s ms\n" "$i" "$rtt"
rtts+=("$rtt")
else
printf " %2d: OK (no rtt)\n" "$i"
fi
else
printf " %2d: FAIL\n" "$i"
fails=$((fails + 1))
fi
done
if [ "$fails" -gt 0 ]; then
FAILED=$((FAILED + fails))
fi
# Drop first ping, compute average of remaining
if [ "${#rtts[@]}" -ge 2 ]; then
local avg
local csv
csv=$(IFS=,; echo "${rtts[*]}")
avg=$(python3 -c "
rtts = [$csv]
trimmed = rtts[1:]
print(f'{sum(trimmed)/len(trimmed):.1f}')
")
echo " Avg (excluding first): ${avg} ms"
PASSED=$((PASSED + ${#rtts[@]}))
elif [ "${#rtts[@]}" -eq 1 ]; then
echo " Only 1 successful ping, no average"
PASSED=$((PASSED + 1))
else
echo " No successful pings"
fi
echo ""
}
echo ""
echo " Ping via Tor onion service (directory mode, Sandbox 1):"
ping_series fips-dir-a "$NPUB_B" "A → B"
ping_series fips-dir-b "$NPUB_A" "B → A"
echo ""
# ── Phase 6: Log analysis ────────────────────────────────────────
echo "Phase 6: Log analysis"
for node in fips-dir-a fips-dir-b; do
panics=$(docker logs "$node" 2>&1 | grep -ci "panic" || true)
errors=$(docker logs "$node" 2>&1 | grep -ci "error" || true)
onion=$(docker logs "$node" 2>&1 | grep -ci "onion" || true)
directory=$(docker logs "$node" 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | grep -ci "directory.mode" || true)
echo " $node: panics=$panics errors=$errors onion_mentions=$onion directory_mentions=$directory"
if [ "$panics" -gt 0 ]; then
echo " WARNING: panics detected in $node logs"
fi
done
echo ""
# ── Cleanup ──────────────────────────────────────────────────────
echo "Cleaning up..."
docker compose down
rm -f configs/node-a.yaml configs/node-b.yaml
echo ""
echo "=== Results: $PASSED passed, $FAILED failed ==="
[ "$FAILED" -eq 0 ] && exit 0 || exit 1
+23
View File
@@ -0,0 +1,23 @@
# Tor configuration for FIPS directory-mode integration test.
# Uses HiddenServiceDir — Tor manages the onion service and key.
#
# NOTE: Sandbox 1 is omitted in Docker tests (requires specific seccomp
# profiles and pre-existing directory ownership). Production deployments
# on bare metal should enable Sandbox 1 per packaging/torrc.fips.
# IsolateSOCKSAuth is required because FIPS uses SOCKS5 username/password
# for per-destination circuit isolation.
SocksPort 127.0.0.1:9050 IsolateSOCKSAuth
# Onion service — Tor manages key and hostname file.
HiddenServiceDir /var/lib/tor/fips_onion_service
HiddenServicePort 8443 127.0.0.1:8443
# Security hardening (subset safe for Docker)
ExitRelay 0
ExitPolicy reject *:*
VanguardsLiteEnabled 1
SafeLogging 1
# Logging
Log notice stderr
+17
View File
@@ -0,0 +1,17 @@
# Tor configuration for directory-mode test node B (socks5-only).
# No onion service — outbound connections only.
# IsolateSOCKSAuth is required because FIPS uses SOCKS5 username/password
# for per-destination circuit isolation.
SocksPort 127.0.0.1:9050 IsolateSOCKSAuth
# Security hardening
ExitRelay 0
ExitPolicy reject *:*
Sandbox 1
VanguardsLiteEnabled 1
ConnectionPadding 1
SafeLogging 1
# Logging
Log notice stderr
@@ -0,0 +1,29 @@
# FIPS Tor test node A — socks5-outbound
#
# Connects outbound to vps-chi (217.77.8.91:443) via Tor SOCKS5 proxy.
# Identity generated per-run to avoid mesh clashes with parallel tests.
node:
identity:
nsec: "{{NSEC_A}}"
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: "127.0.0.1"
transports:
tor:
socks5_addr: "tor-daemon:9050"
peers:
- npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
alias: "vps-chi"
addresses:
- transport: tor
addr: "217.77.8.91:443"
connect_policy: auto_connect
@@ -0,0 +1,29 @@
# FIPS Tor test node B — socks5-outbound
#
# Connects outbound to vps-chi (217.77.8.91:443) via Tor SOCKS5 proxy.
# Identity generated per-run to avoid mesh clashes with parallel tests.
node:
identity:
nsec: "{{NSEC_B}}"
tun:
enabled: true
name: fips0
mtu: 1280
dns:
enabled: true
bind_addr: "127.0.0.1"
transports:
tor:
socks5_addr: "tor-daemon:9050"
peers:
- npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
alias: "vps-chi"
addresses:
- transport: tor
addr: "217.77.8.91:443"
connect_policy: auto_connect
@@ -0,0 +1,66 @@
# Tor transport integration test — socks5-outbound
#
# Topology:
# [fips-a] --tor/socks5--> vps-chi (217.77.8.91:443) <--tor/socks5-- [fips-b]
#
# Both FIPS nodes connect outbound through a local Tor daemon's SOCKS5
# proxy to vps-chi's TCP listener. vps-chi routes between them.
# Ping between fips-a and fips-b validates the full Tor transport path.
networks:
tor-test:
driver: bridge
services:
tor-daemon:
image: osminogin/tor-simple:latest
container_name: tor-daemon
restart: "no"
volumes:
- ../common/torrc:/etc/tor/torrc:ro
networks:
tor-test:
fips-a:
build:
context: ../common
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
restart: "no"
container_name: fips-tor-a
hostname: fips-tor-a
depends_on:
- tor-daemon
volumes:
- ../common/resolv.conf:/etc/resolv.conf:ro
- ./configs/node-a.yaml:/etc/fips/fips.yaml:ro
environment:
- RUST_LOG=info,fips::transport::tor=debug
networks:
tor-test:
fips-b:
build:
context: ../common
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
restart: "no"
container_name: fips-tor-b
hostname: fips-tor-b
depends_on:
- tor-daemon
volumes:
- ../common/resolv.conf:/etc/resolv.conf:ro
- ./configs/node-b.yaml:/etc/fips/fips.yaml:ro
environment:
- RUST_LOG=info,fips::transport::tor=debug
networks:
tor-test:
+230
View File
@@ -0,0 +1,230 @@
#!/bin/bash
# Tor transport integration test.
#
# Validates end-to-end connectivity through a real Tor network:
# fips-a --tor/socks5--> vps-chi <--tor/socks5-- fips-b
#
# Both local FIPS nodes connect outbound through a local Tor daemon
# to vps-chi's TCP listener (217.77.8.91:443). Once both are peered
# with vps-chi, traffic between fips-a and fips-b is routed through it.
#
# Each run generates ephemeral identities to avoid mesh clashes when
# multiple instances of this test run concurrently.
#
# Usage: ./tor-test.sh
#
# Timings (approximate):
# Tor bootstrap: 10-30s
# First SOCKS5 attempt: may timeout at 60s (circuits not ready)
# Retry + circuit setup: 10-30s
# FIPS handshake: ~1s per peer
# Routing convergence: ~5s
# Total: ~90-180s
#
# This test requires internet access for the Tor daemon.
set -e
trap 'echo ""; echo "Test interrupted — cleaning up..."; docker compose down 2>/dev/null; exit 130' INT
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOR_DIR="$SCRIPT_DIR/.."
DERIVE_KEYS="$SCRIPT_DIR/../../../static/scripts/derive-keys.py"
cd "$TOR_DIR"
PASSED=0
FAILED=0
TIMEOUT_PING=15
MAX_WAIT_TOR=90
MAX_WAIT_PEER=180
# Count connected peers for a node using fipsctl show peers JSON output
count_connected_peers() {
local container="$1"
docker exec "$container" fipsctl show peers 2>/dev/null \
| python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
print(sum(1 for p in data.get('peers', []) if p.get('connectivity') == 'connected'))
except:
print(0)
" 2>/dev/null || echo 0
}
echo "=== FIPS Tor Transport Integration Test ==="
echo ""
# ── Phase 0: Generate ephemeral identities ───────────────────────
echo "Phase 0: Generating ephemeral identities..."
MESH_NAME="tor-test-$(date +%s)-$$"
echo " Mesh name: $MESH_NAME"
KEYS_A=$(python3 "$DERIVE_KEYS" "$MESH_NAME" "a")
NSEC_A=$(echo "$KEYS_A" | grep "^nsec=" | cut -d= -f2)
NPUB_A=$(echo "$KEYS_A" | grep "^npub=" | cut -d= -f2)
KEYS_B=$(python3 "$DERIVE_KEYS" "$MESH_NAME" "b")
NSEC_B=$(echo "$KEYS_B" | grep "^nsec=" | cut -d= -f2)
NPUB_B=$(echo "$KEYS_B" | grep "^npub=" | cut -d= -f2)
echo " Node A: $NPUB_A"
echo " Node B: $NPUB_B"
# Generate configs from templates
sed "s/{{NSEC_A}}/$NSEC_A/" configs/node-a.yaml.tmpl > configs/node-a.yaml
sed "s/{{NSEC_B}}/$NSEC_B/" configs/node-b.yaml.tmpl > configs/node-b.yaml
echo " Configs generated"
echo ""
# ── Phase 1: Build and start ─────────────────────────────────────
echo "Phase 1: Starting Tor daemon and FIPS nodes..."
docker compose down 2>/dev/null || true
docker compose up -d --build
echo ""
# ── Phase 2: Wait for Tor bootstrap ─────────────────────────────
echo "Phase 2: Waiting for Tor daemon to bootstrap (up to ${MAX_WAIT_TOR}s)..."
elapsed=0
while [ "$elapsed" -lt "$MAX_WAIT_TOR" ]; do
if docker logs tor-daemon 2>&1 | grep -q "Bootstrapped 100%"; then
echo " Tor bootstrapped after ${elapsed}s"
break
fi
sleep 5
elapsed=$((elapsed + 5))
echo " ${elapsed}s..."
done
if [ "$elapsed" -ge "$MAX_WAIT_TOR" ]; then
echo " FAIL: Tor daemon did not bootstrap within ${MAX_WAIT_TOR}s"
echo ""
echo "Tor daemon logs:"
docker logs tor-daemon 2>&1 | tail -20
docker compose down
exit 1
fi
echo ""
# ── Phase 3: Wait for FIPS peers via Tor ─────────────────────────
echo "Phase 3: Waiting for FIPS nodes to peer with vps-chi via Tor (up to ${MAX_WAIT_PEER}s)..."
echo " (First SOCKS5 attempt may timeout while Tor builds circuits)"
peers_a=0
peers_b=0
elapsed=0
while [ "$elapsed" -lt "$MAX_WAIT_PEER" ]; do
peers_a=$(count_connected_peers fips-tor-a)
peers_b=$(count_connected_peers fips-tor-b)
if [ "$peers_a" -ge 1 ] && [ "$peers_b" -ge 1 ]; then
echo " Both nodes have connected peers after ${elapsed}s (A: ${peers_a}, B: ${peers_b})"
break
fi
sleep 10
elapsed=$((elapsed + 10))
echo " ${elapsed}s... (A peers: ${peers_a}, B peers: ${peers_b})"
done
if [ "$peers_a" -lt 1 ] || [ "$peers_b" -lt 1 ]; then
echo " FAIL: Peers not established within ${MAX_WAIT_PEER}s"
echo ""
echo "Node A logs (last 30 lines):"
docker logs fips-tor-a 2>&1 | tail -30
echo ""
echo "Node B logs (last 30 lines):"
docker logs fips-tor-b 2>&1 | tail -30
docker compose down
exit 1
fi
# Extra convergence time for routing
echo " Waiting 10s for routing convergence..."
sleep 10
echo ""
# ── Phase 4: Connectivity tests ──────────────────────────────────
echo "Phase 4: Connectivity tests"
PING_COUNT=11
ping_series() {
local from="$1"
local to_npub="$2"
local label="$3"
echo " $label ($PING_COUNT pings, dropping first):"
local rtts=()
local fails=0
for i in $(seq 1 "$PING_COUNT"); do
local output
if output=$(docker exec "$from" ping6 -c 1 -W "$TIMEOUT_PING" "${to_npub}.fips" 2>&1); then
local rtt
rtt=$(echo "$output" | grep -oE 'time=[0-9.]+' | cut -d= -f2)
if [ -n "$rtt" ]; then
printf " %2d: %s ms\n" "$i" "$rtt"
rtts+=("$rtt")
else
printf " %2d: OK (no rtt)\n" "$i"
fi
else
printf " %2d: FAIL\n" "$i"
fails=$((fails + 1))
fi
done
if [ "$fails" -gt 0 ]; then
FAILED=$((FAILED + fails))
fi
# Drop first ping, compute average of remaining
if [ "${#rtts[@]}" -ge 2 ]; then
local avg
local csv
csv=$(IFS=,; echo "${rtts[*]}")
avg=$(python3 -c "
rtts = [$csv]
trimmed = rtts[1:]
print(f'{sum(trimmed)/len(trimmed):.1f}')
")
echo " Avg (excluding first): ${avg} ms"
PASSED=$((PASSED + ${#rtts[@]}))
elif [ "${#rtts[@]}" -eq 1 ]; then
echo " Only 1 successful ping, no average"
PASSED=$((PASSED + 1))
else
echo " No successful pings"
fi
echo ""
}
echo ""
echo " Ping via Tor (routed through vps-chi):"
ping_series fips-tor-a "$NPUB_B" "A → B"
ping_series fips-tor-b "$NPUB_A" "B → A"
echo ""
# ── Phase 5: Log analysis ────────────────────────────────────────
echo "Phase 5: Log analysis"
for node in fips-tor-a fips-tor-b; do
panics=$(docker logs "$node" 2>&1 | grep -ci "panic" || true)
errors=$(docker logs "$node" 2>&1 | grep -ci "error" || true)
socks5=$(docker logs "$node" 2>&1 | grep -ci "socks5\|socks" || true)
echo " $node: panics=$panics errors=$errors socks5_mentions=$socks5"
if [ "$panics" -gt 0 ]; then
echo " WARNING: panics detected in $node logs"
fi
done
echo ""
# ── Cleanup ──────────────────────────────────────────────────────
echo "Cleaning up..."
docker compose down
rm -f configs/node-a.yaml configs/node-b.yaml
echo ""
echo "=== Results: $PASSED passed, $FAILED failed ==="
[ "$FAILED" -eq 0 ] && exit 0 || exit 1