diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..bb13dae --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use devenv diff --git a/.github/workflows/daily-image-verification.yml b/.github/workflows/daily-image-verification.yml new file mode 100644 index 0000000..2dbbd8d --- /dev/null +++ b/.github/workflows/daily-image-verification.yml @@ -0,0 +1,236 @@ +name: "Daily Live Image Verification" + +on: + schedule: + # Daily at 04:00 UTC (before European business hours start) + - cron: "0 4 * * *" + workflow_dispatch: + inputs: + image_tag: + description: "Docker Image Tag to test (default: latest)" + required: true + default: "latest" + +jobs: + verify-live-image: + name: "Live Tor Canary Verification" + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + # ------------------------------------------------------------- + # 1. Determine Host IP (for anti-leak assertion) + # ------------------------------------------------------------- + - name: Determine Host IP + id: host-info + run: | + RUNNER_IP=$(curl -s --connect-timeout 10 https://api.ipify.org || curl -s --connect-timeout 10 https://icanhazip.com) + echo "Host Runner IP: $RUNNER_IP" + echo "runner_ip=$RUNNER_IP" >> "$GITHUB_OUTPUT" + + # ------------------------------------------------------------- + # 2. Pull and start current Docker image + # ------------------------------------------------------------- + - name: Pull and Run torproxy Container + id: run-container + run: | + IMAGE_TAG="${{ github.event.inputs.image_tag || 'latest' }}" + IMAGE_NAME="hapheus/torproxy:${IMAGE_TAG}" + echo "Pulling Docker Image: $IMAGE_NAME..." + docker pull "$IMAGE_NAME" + + echo "Starting Container..." + CONTAINER_ID=$(docker run -d \ + --name torproxy-canary \ + -p 8118:8118 \ + -p 9050:9050 \ + -p 8080:8080 \ + "$IMAGE_NAME") + echo "container_id=$CONTAINER_ID" >> "$GITHUB_OUTPUT" + + # ------------------------------------------------------------- + # 3. Live check: wait for 100% Tor bootstrap (SLA: max 180s) + # ------------------------------------------------------------- + - name: Wait for Tor 100% Bootstrap + timeout-minutes: 4 + run: | + echo "Waiting for Tor Bootstrap via REST API (http://localhost:8080/status)..." + BOOTSTRAPPED=false + for i in {1..36}; do + STATUS_JSON=$(curl -s --connect-timeout 5 http://localhost:8080/status || echo "{}") + STATUS=$(echo "$STATUS_JSON" | jq -r '.status // "unknown"') + DETAIL=$(echo "$STATUS_JSON" | jq -r '.detail // ""') + + echo "Attempt $i/36: Status='$STATUS', Detail='$DETAIL'" + + if [ "$STATUS" = "connected" ]; then + echo "✅ Tor successfully bootstrapped and connected!" + BOOTSTRAPPED=true + break + fi + sleep 5 + done + + if [ "$BOOTSTRAPPED" != "true" ]; then + echo "❌ ERROR: Tor failed to reach 100% bootstrap within 180 seconds." + echo "=== Container Logs ===" + docker logs torproxy-canary + exit 1 + fi + + # ------------------------------------------------------------- + # 4. HTTP proxy (Privoxy :8118) egress & anonymity check + # ------------------------------------------------------------- + - name: Verify HTTP Proxy (Port 8118) Egress & Anonymity + run: | + RUNNER_IP="${{ steps.host-info.outputs.runner_ip }}" + echo "Testing Privoxy HTTP Proxy via http://127.0.0.1:8118..." + + RESP=$(curl -s --connect-timeout 20 --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip) + echo "Privoxy Response: $RESP" + + IS_TOR=$(echo "$RESP" | jq -r '.IsTor') + EXIT_IP=$(echo "$RESP" | jq -r '.IP') + + if [ "$IS_TOR" != "true" ]; then + echo "❌ ERROR: Request via Privoxy is NOT recognized as Tor traffic (IsTor=$IS_TOR)." + exit 1 + fi + + if [ "$EXIT_IP" = "$RUNNER_IP" ]; then + echo "❌ CRITICAL: IP Leak detected! Exit IP matches Runner Host IP ($EXIT_IP)." + exit 1 + fi + + echo "✅ HTTP Proxy OK: Exit-IP is $EXIT_IP (IsTor=true, IP != Host)" + echo "initial_http_ip=$EXIT_IP" >> "$GITHUB_ENV" + + # ------------------------------------------------------------- + # 5. SOCKS5 proxy (Tor :9050) egress & remote DNS check + # ------------------------------------------------------------- + - name: Verify SOCKS5 Proxy (Port 9050) Egress & DNS Resolution + run: | + RUNNER_IP="${{ steps.host-info.outputs.runner_ip }}" + echo "Testing Tor SOCKS5 Proxy via socks5h://127.0.0.1:9050 (Remote DNS)..." + + RESP=$(curl -s --connect-timeout 20 --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip) + echo "SOCKS5 Response: $RESP" + + IS_TOR=$(echo "$RESP" | jq -r '.IsTor') + EXIT_IP=$(echo "$RESP" | jq -r '.IP') + + if [ "$IS_TOR" != "true" ]; then + echo "❌ ERROR: Request via SOCKS5 is NOT recognized as Tor traffic (IsTor=$IS_TOR)." + exit 1 + fi + + if [ "$EXIT_IP" = "$RUNNER_IP" ]; then + echo "❌ CRITICAL: SOCKS5 IP Leak detected! ($EXIT_IP)." + exit 1 + fi + + echo "✅ SOCKS5 Proxy OK: Exit-IP is $EXIT_IP (IsTor=true)" + + # ------------------------------------------------------------- + # 6. Circuit rotation (/reconnect -> SIGNAL NEWNYM) + # ------------------------------------------------------------- + - name: Verify Circuit Rotation (/reconnect -> SIGNAL NEWNYM) + run: | + OLD_IP="${{ env.initial_http_ip }}" + echo "Current Exit IP before rotation: $OLD_IP" + + echo "Triggering IP reconnect via REST API..." + RECONNECT_RESP=$(curl -s -X POST http://localhost:8080/reconnect) + echo "Reconnect Response: $RECONNECT_RESP" + + # Tor rate-limits SIGNAL NEWNYM if sent within 10s of a previous signal + echo "Waiting 15 seconds for new Tor circuit establishment..." + sleep 15 + + NEW_RESP=$(curl -s --connect-timeout 20 --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip) + echo "New Check Response: $NEW_RESP" + + IS_TOR_NEW=$(echo "$NEW_RESP" | jq -r '.IsTor') + NEW_IP=$(echo "$NEW_RESP" | jq -r '.IP') + + if [ "$IS_TOR_NEW" != "true" ]; then + echo "❌ ERROR: Rotated circuit is NOT recognized as Tor traffic." + exit 1 + fi + + echo "Initial IP: $OLD_IP" + echo "New IP: $NEW_IP" + + if [ "$NEW_IP" = "$OLD_IP" ]; then + echo "⚠️ Warning: Exit IP unchanged after rotation ($NEW_IP). Note: Tor may select same exit node if circuit pool is small, but circuit was refreshed." + else + echo "✅ Circuit successfully rotated to new Exit-IP: $NEW_IP" + fi + + # ------------------------------------------------------------- + # 7. Generate step summary + # ------------------------------------------------------------- + - name: Generate Step Summary + if: always() + run: | + echo "### 🛡️ Daily Torproxy Canary Verification Summary" >> "$GITHUB_STEP_SUMMARY" + echo "- **Image:** \`hapheus/torproxy:${{ github.event.inputs.image_tag || 'latest' }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Runner Host IP:** \`${{ steps.host-info.outputs.runner_ip }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Initial Tor Exit IP:** \`${{ env.initial_http_ip }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Result:** ${{ job.status == 'success' && '✅ PASSED' || '❌ FAILED' }}" >> "$GITHUB_STEP_SUMMARY" + + # ------------------------------------------------------------- + # 8. Stop and clean up container + # ------------------------------------------------------------- + - name: Cleanup Container + if: always() + run: | + echo "Stopping and cleaning up container..." + docker stop torproxy-canary || true + docker rm -f torproxy-canary || true + + # ------------------------------------------------------------- + # 9. Alert on failure (GitHub issue automation) + # ------------------------------------------------------------- + - name: Alert on Failure (Create/Update Issue) + if: failure() + uses: actions/github-script@v7 + with: + script: | + const issueTitle = "🚨 Daily Live Image Verification Failed"; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const body = `### 🚨 Daily Canary Failure Detected\n\n` + + `The scheduled daily verification of \`hapheus/torproxy:latest\` failed.\n\n` + + `- **Run:** [View Action Run](${runUrl})\n` + + `- **Date:** ${new Date().toISOString()}\n` + + `- **Cause:** Bootstrap timeout, proxy failure, or Tor network / Directory Authority outage.\n\n` + + `Please check the workflow logs for container output and exit status.`; + + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: ['canary-alert'] + }); + + if (issues.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issues[0].number, + body: `**Repeated Failure:** Canary run failed again on ${new Date().toISOString()}.\n[View Run](${runUrl})` + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: body, + labels: ['canary-alert', 'bug', 'priority/p1-urgent'] + }); + } diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 0000000..fe81740 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,151 @@ +name: "PR Quality & Smoke Gate" + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +jobs: + lint-and-validate: + name: "Lint & Static Analysis" + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Linting Tools + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Run Ruff Linter + run: | + ruff check . + + - name: Validate Shell Script Syntax + run: | + bash -n docker/start.sh + bash -n docker/healthcheck.sh + + - name: Run ShellCheck + run: | + shellcheck docker/start.sh + shellcheck docker/healthcheck.sh + + unit-tests: + name: "Hermetic Unit Tests" + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Test Dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + + - name: Run Pytest (Hermetic Unit Tests) + run: | + pytest tests/unit/ -v + + docker-smoke: + name: "Docker Build & Container Smoke Test" + runs-on: ubuntu-latest + needs: [lint-and-validate, unit-tests] + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Log in to Docker Hub (Rate Limit Exemption) + uses: docker/login-action@v3 + if: env.DOCKERHUB_TOKEN != '' + with: + registry: docker.io + username: hapheus + password: ${{ secrets.DOCKERHUB_TOKEN }} + env: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build Docker Image + run: | + BUILD_SUCCESS=false + for i in {1..5}; do + echo "Attempt $i: Building Docker image..." + if docker build -t torproxy:smoke-test ./docker; then + BUILD_SUCCESS=true + break + fi + echo "Build failed (possibly Docker Hub rate limit 429). Retrying in 10s..." + sleep 10 + done + if [ "$BUILD_SUCCESS" != "true" ]; then + echo "❌ Failed to build Docker image after 5 attempts." + exit 1 + fi + + - name: Run Container for Smoke Test + id: run-container + run: | + CONTAINER_ID=$(docker run -d \ + --name torproxy-smoke \ + -p 8118:8118 \ + -p 9050:9050 \ + -p 8080:8080 \ + torproxy:smoke-test) + echo "container_id=$CONTAINER_ID" >> "$GITHUB_OUTPUT" + # Brief sleep to allow processes to start + sleep 3 + + - name: Verify Non-Root User Execution + run: | + CONTAINER_UID=$(docker exec torproxy-smoke id -u) + echo "Container User UID: $CONTAINER_UID" + if [ "$CONTAINER_UID" -eq 0 ]; then + echo "❌ CRITICAL SECURITY ERROR: Container is running as root (UID 0)!" + exit 1 + fi + echo "✅ Container is running as non-root user (UID: $CONTAINER_UID)." + + - name: Verify Daemon Processes + run: | + PROCS=$(docker top torproxy-smoke) + echo "$PROCS" + echo "$PROCS" | grep -q "tor" || (echo "❌ tor daemon is not running" && exit 1) + echo "$PROCS" | grep -q "privoxy" || (echo "❌ privoxy is not running" && exit 1) + echo "$PROCS" | grep -q "python3" || (echo "❌ API process is not running" && exit 1) + echo "✅ All required daemons (tor, privoxy, api.py) are running." + + - name: Verify Graceful Shutdown + run: | + echo "Stopping container with 5s timeout to test SIGTERM handling..." + STOP_START=$(date +%s) + docker stop -t 5 torproxy-smoke + STOP_END=$(date +%s) + STOP_DURATION=$((STOP_END - STOP_START)) + echo "Container stopped in $STOP_DURATION seconds." + if [ "$STOP_DURATION" -ge 10 ]; then + echo "❌ Container failed graceful shutdown within timeout (was SIGKILLed)." + exit 1 + fi + echo "✅ Graceful shutdown successful." + + - name: Cleanup Smoke Container + if: always() + run: | + docker rm -f torproxy-smoke || true diff --git a/.gitignore b/.gitignore index be47843..7ba0abf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,54 @@ -.DS_Store +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environments (ADR-009) +.venv/ +venv/ +ENV/ +env/ + +# Testing & Linting (ADR-004 & ADR-005) +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Environment and Secrets .env +.env.* +!.env.example + +# OS / IDE +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*~ + +# Devenv / Direnv (ADR-001) +.devenv/ +.direnv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c05e4d6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.10.0 + hooks: + - id: shellcheck + files: ^docker/.*\.sh$ diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..8433e7f --- /dev/null +++ b/devenv.nix @@ -0,0 +1,20 @@ +{ pkgs, ... }: + +{ + # ADR-001: devenv configuration for torproxy + languages.python = { + enable = true; + venv.enable = true; + }; + + packages = [ + pkgs.git + pkgs.ruff + pkgs.shellcheck + ]; + + pre-commit.hooks = { + ruff.enable = true; + shellcheck.enable = true; + }; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..c7cb5ce --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,3 @@ +inputs: + nixpkgs: + url: github:NixOS/nixpkgs/nixpkgs-unstable diff --git a/docker/.dockerignore b/docker/.dockerignore index 47ebb8f..99f18c9 100644 --- a/docker/.dockerignore +++ b/docker/.dockerignore @@ -5,3 +5,9 @@ README.md CONTRIBUTING.md LICENSE docker-compose.yml +__pycache__ +*.py[cod] +*$py.class +.venv +.pytest_cache +.ruff_cache diff --git a/docker/__pycache__/api.cpython-310.pyc b/docker/__pycache__/api.cpython-310.pyc deleted file mode 100644 index 5da21f1..0000000 Binary files a/docker/__pycache__/api.cpython-310.pyc and /dev/null differ diff --git a/docker/api.py b/docker/api.py index 41c2c7c..a739391 100644 --- a/docker/api.py +++ b/docker/api.py @@ -1,41 +1,44 @@ #!/usr/bin/env python3 import json -import socket -import urllib.request -from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler import os -import time import re +import socket +import time +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -API_PORT = int(os.environ.get("TORPROXY_API_PORT", 8080)) -PROXY_PORT = int(os.environ.get("TORPROXY_LISTEN_PORT", 8118)) +API_PORT = int(os.environ.get("TORPROXY_API_PORT", "8080")) +PROXY_PORT = int(os.environ.get("TORPROXY_LISTEN_PORT", "8118")) # IP Caching configuration IP_CACHE_TTL = 30 # seconds _cached_ip = None _cached_ip_time = 0 + def send_tor_command(command): try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(3) - s.connect(('127.0.0.1', 9051)) - s.sendall(b'AUTHENTICATE\r\n') + s.connect(("127.0.0.1", 9051)) + s.sendall(b"AUTHENTICATE\r\n") resp = s.recv(1024) - if b'250' not in resp: + if b"250" not in resp: return None, "Tor control authentication failed" - - s.sendall(command.encode('utf-8') + b'\r\n') + + s.sendall(command.encode("utf-8") + b"\r\n") resp = s.recv(1024) - return resp.decode('utf-8'), None - except Exception as e: + return resp.decode("utf-8"), None + except Exception as e: # noqa: BLE001 return None, str(e) + def clear_ip_cache(): global _cached_ip, _cached_ip_time _cached_ip = None _cached_ip_time = 0 + def get_current_ip(): global _cached_ip, _cached_ip_time now = time.time() @@ -43,31 +46,32 @@ def get_current_ip(): return _cached_ip, None proxy_support = urllib.request.ProxyHandler({ - 'http': f'http://127.0.0.1:{PROXY_PORT}', - 'https': f'http://127.0.0.1:{PROXY_PORT}' + "http": f"http://127.0.0.1:{PROXY_PORT}", + "https": f"http://127.0.0.1:{PROXY_PORT}", }) opener = urllib.request.build_opener(proxy_support) try: # 3 seconds timeout to keep API responsive - response = opener.open('https://check.torproject.org/api/ip', timeout=3) - data = json.loads(response.read().decode('utf-8')) + response = opener.open("https://check.torproject.org/api/ip", timeout=3) + data = json.loads(response.read().decode("utf-8")) # check.torproject.org uses an uppercase "IP" key. Accept the # lowercase variant as well for compatibility with equivalent APIs. - ip = data.get('IP') or data.get('ip') + ip = data.get("IP") or data.get("ip") if ip: _cached_ip = ip _cached_ip_time = now return ip, None return None, "No IP key in response" - except Exception as e: + except Exception as e: # noqa: BLE001 return None, str(e) + def get_tor_bootstrap_status(): - resp, err = send_tor_command('GETINFO status/bootstrap-phase') + resp, err = send_tor_command("GETINFO status/bootstrap-phase") if err: return None, f"Failed to query Tor control: {err}" - - # Example format: 250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=100 TAG=done SUMMARY="Handshake..." + + # Format: 250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=100 TAG=done SUMMARY="..." match = re.search(r'PROGRESS=(\d+)\s+TAG=(\S+)\s+SUMMARY="([^"]+)"', resp) if match: progress = int(match.group(1)) @@ -77,21 +81,22 @@ def get_tor_bootstrap_status(): "progress": progress, "tag": tag, "summary": summary, - "bootstrapped": progress == 100 + "bootstrapped": progress == 100, }, None return None, "Failed to parse bootstrap status" + class APIHandler(BaseHTTPRequestHandler): def log_message(self, format, *args): # Print logs to stdout for Docker logging - print(f"[API] {self.address_string()} - - {format%args}") + print(f"[API] {self.address_string()} - - {format % args}") def send_json(self, status_code, data): self.send_response(status_code) - self.send_header('Content-Type', 'application/json') - self.send_header('Access-Control-Allow-Origin', '*') + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() - self.wfile.write(json.dumps(data).encode('utf-8')) + self.wfile.write(json.dumps(data).encode("utf-8")) def do_GET(self): self.handle_request() @@ -100,71 +105,78 @@ def do_POST(self): self.handle_request() def handle_request(self): - path = self.path.strip('/') - - if path in ('status', ''): - resp, err = send_tor_command('GETCONF DisableNetwork') + path = self.path.strip("/") + + if path in ("status", ""): + resp, err = send_tor_command("GETCONF DisableNetwork") if err: self.send_json(500, {"error": f"Failed to query Tor: {err}"}) return - + network_enabled = "DisableNetwork=0" in resp - + if not network_enabled: self.send_json(200, { "status": "disconnected", - "ip": None + "ip": None, }) return - + # Query Tor's internal bootstrap status bootstrap, b_err = get_tor_bootstrap_status() if not bootstrap or not bootstrap["bootstrapped"]: - detail = bootstrap["summary"] if bootstrap else (b_err or "Unknown bootstrap status") + detail = ( + bootstrap["summary"] + if bootstrap + else (b_err or "Unknown bootstrap status") + ) self.send_json(200, { "status": "connecting", "ip": None, - "detail": f"Tor bootstrapping: {detail}" + "detail": f"Tor bootstrapping: {detail}", }) return - + ip, err = get_current_ip() if ip: self.send_json(200, { "status": "connected", - "ip": ip + "ip": ip, }) else: self.send_json(200, { "status": "connecting", "ip": None, - "detail": f"Failed to route traffic (Tor bootstrapping?): {err}" + "detail": f"Failed to route traffic (Tor bootstrapping?): {err}", }) - elif path == 'ip': + elif path == "ip": ip, err = get_current_ip() if ip: self.send_json(200, {"ip": ip}) else: - self.send_json(503, {"error": "Could not retrieve IP. Proxy may be offline or connecting.", "detail": err}) + self.send_json(503, { + "error": "Could not retrieve IP. Proxy may be offline or connecting.", + "detail": err, + }) - elif path == 'connect': - _, err = send_tor_command('SETCONF DisableNetwork=0') + elif path == "connect": + _, err = send_tor_command("SETCONF DisableNetwork=0") if err: self.send_json(500, {"error": f"Failed to enable Tor network: {err}"}) else: self.send_json(200, {"action": "connect", "status": "success"}) - elif path == 'disconnect': - _, err = send_tor_command('SETCONF DisableNetwork=1') + elif path == "disconnect": + _, err = send_tor_command("SETCONF DisableNetwork=1") if err: self.send_json(500, {"error": f"Failed to disable Tor network: {err}"}) else: clear_ip_cache() self.send_json(200, {"action": "disconnect", "status": "success"}) - elif path == 'reconnect': - _, err = send_tor_command('SIGNAL NEWNYM') + elif path == "reconnect": + _, err = send_tor_command("SIGNAL NEWNYM") if err: self.send_json(500, {"error": f"Failed to trigger reconnect: {err}"}) else: @@ -172,10 +184,14 @@ def handle_request(self): self.send_json(200, {"action": "reconnect", "status": "success"}) else: - self.send_json(404, {"error": "Not Found", "supported_paths": ["/status", "/ip", "/connect", "/disconnect", "/reconnect"]}) + self.send_json(404, { + "error": "Not Found", + "supported_paths": ["/status", "/ip", "/connect", "/disconnect", "/reconnect"], + }) + def run(): - server_address = ('0.0.0.0', API_PORT) + server_address = ("0.0.0.0", API_PORT) httpd = ThreadingHTTPServer(server_address, APIHandler) print(f"Starting API server on port {API_PORT}...") try: @@ -185,5 +201,6 @@ def run(): finally: httpd.server_close() -if __name__ == '__main__': + +if __name__ == "__main__": run() diff --git a/docker/privoxy.config.template b/docker/privoxy.config.template index 0405f3d..7f71034 100644 --- a/docker/privoxy.config.template +++ b/docker/privoxy.config.template @@ -2,4 +2,4 @@ listen-address __TORPROXY_LISTEN_ADDRESS__:__TORPROXY_LISTEN_PORT__ # Send every request through Tor. The trailing dot enables remote DNS lookups. -forward-socks5t / 127.0.0.1:9050 . +forward-socks5t / 127.0.0.1:__TORPROXY_SOCKS_PORT__ . diff --git a/docker/start.sh b/docker/start.sh index c7ca2be..4307f3c 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -37,6 +37,7 @@ validate_configuration sed \ -e "s|__TORPROXY_LISTEN_ADDRESS__|$TORPROXY_LISTEN_ADDRESS|" \ -e "s|__TORPROXY_LISTEN_PORT__|$TORPROXY_LISTEN_PORT|" \ + -e "s|__TORPROXY_SOCKS_PORT__|$TORPROXY_SOCKS_PORT|" \ /etc/privoxy/config.template > /tmp/privoxy.config sed \ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4282ee1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "torproxy" +version = "1.0.1" +description = "A minimal HTTP and SOCKS5 proxy routed through the Tor network with local REST control API" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [ + { name = "Franz Haberfellner" } +] +dependencies = [] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "ruff>=0.1.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +markers = [ + "unit: Hermetic unit tests without external network or daemon requirements", + "smoke: Container smoke and lifecycle tests", + "live: Live canary tests against the active Tor network", +] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8d8cf89 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import os +import sys + +# Ensure docker/ directory is on sys.path for api module import +docker_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "docker")) +if docker_dir not in sys.path: + sys.path.insert(0, docker_dir) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py new file mode 100644 index 0000000..0a5d017 --- /dev/null +++ b/tests/unit/test_api.py @@ -0,0 +1,308 @@ +import json +import socket +import threading +import time +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer +from unittest.mock import MagicMock, patch + +import api +import pytest + + +@pytest.fixture(autouse=True) +def reset_ip_cache(): + api.clear_ip_cache() + yield + api.clear_ip_cache() + + +@pytest.mark.unit +class TestTorControlProtocol: + @patch("socket.socket") + def test_send_tor_command_success(self, mock_socket_class): + mock_sock = MagicMock() + mock_socket_class.return_value.__enter__.return_value = mock_sock + bootstrap_reply = ( + b"250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=100 TAG=done " + b'SUMMARY="Handshake"\r\n' + b"250 OK\r\n" + ) + mock_sock.recv.side_effect = [ + b"250 OK\r\n", + bootstrap_reply, + ] + + resp, err = api.send_tor_command("GETINFO status/bootstrap-phase") + + assert err is None + assert "PROGRESS=100" in resp + mock_sock.connect.assert_called_once_with(("127.0.0.1", 9051)) + mock_sock.sendall.assert_any_call(b"AUTHENTICATE\r\n") + + @patch("socket.socket") + def test_send_tor_command_auth_failure(self, mock_socket_class): + mock_sock = MagicMock() + mock_socket_class.return_value.__enter__.return_value = mock_sock + mock_sock.recv.return_value = b"515 Authentication failed: wrong password\r\n" + + resp, err = api.send_tor_command("GETCONF DisableNetwork") + + assert resp is None + assert err == "Tor control authentication failed" + + @patch("socket.socket") + def test_send_tor_command_socket_timeout(self, mock_socket_class): + mock_sock = MagicMock() + mock_socket_class.return_value.__enter__.return_value = mock_sock + mock_sock.connect.side_effect = socket.timeout("timed out") + + resp, err = api.send_tor_command("GETCONF DisableNetwork") + + assert resp is None + assert "timed out" in err + + +@pytest.mark.unit +class TestTorBootstrapStatus: + @patch("api.send_tor_command") + def test_bootstrap_complete(self, mock_send): + reply = ( + '250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=100 TAG=done SUMMARY="Done"\r\n' + "250 OK" + ) + mock_send.return_value = (reply, None) + + status, err = api.get_tor_bootstrap_status() + + assert err is None + assert status == { + "progress": 100, + "tag": "done", + "summary": "Done", + "bootstrapped": True, + } + + @patch("api.send_tor_command") + def test_bootstrap_in_progress(self, mock_send): + reply = ( + "250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=50 TAG=conn_dir " + 'SUMMARY="Connecting to directory mirror"\r\n250 OK' + ) + mock_send.return_value = (reply, None) + + status, err = api.get_tor_bootstrap_status() + + assert err is None + assert status == { + "progress": 50, + "tag": "conn_dir", + "summary": "Connecting to directory mirror", + "bootstrapped": False, + } + + @patch("api.send_tor_command") + def test_bootstrap_parse_failure(self, mock_send): + mock_send.return_value = ("250 OK Unrecognized format", None) + + status, err = api.get_tor_bootstrap_status() + + assert status is None + assert err == "Failed to parse bootstrap status" + + @patch("api.send_tor_command") + def test_bootstrap_query_error(self, mock_send): + mock_send.return_value = (None, "Socket closed") + + status, err = api.get_tor_bootstrap_status() + + assert status is None + assert "Failed to query Tor control: Socket closed" in err + + +@pytest.mark.unit +class TestIPResolutionAndCaching: + def test_clear_ip_cache(self): + api._cached_ip = "192.0.2.1" + api._cached_ip_time = time.time() + + api.clear_ip_cache() + + assert api._cached_ip is None + assert api._cached_ip_time == 0 + + def test_cached_ip_within_ttl(self): + api._cached_ip = "198.51.100.42" + api._cached_ip_time = time.time() + + with patch("urllib.request.build_opener") as mock_opener: + ip, err = api.get_current_ip() + assert ip == "198.51.100.42" + assert err is None + mock_opener.assert_not_called() + + @patch("urllib.request.build_opener") + def test_get_current_ip_uppercase_key(self, mock_build_opener): + mock_resp = MagicMock() + mock_resp.read.return_value = b'{"IP": "203.0.113.10", "IsTor": true}' + mock_opener = MagicMock() + mock_opener.open.return_value = mock_resp + mock_build_opener.return_value = mock_opener + + ip, err = api.get_current_ip() + + assert ip == "203.0.113.10" + assert err is None + assert api._cached_ip == "203.0.113.10" + + @patch("urllib.request.build_opener") + def test_get_current_ip_lowercase_key(self, mock_build_opener): + mock_resp = MagicMock() + mock_resp.read.return_value = b'{"ip": "203.0.113.20"}' + mock_opener = MagicMock() + mock_opener.open.return_value = mock_resp + mock_build_opener.return_value = mock_opener + + ip, err = api.get_current_ip() + + assert ip == "203.0.113.20" + assert err is None + + @patch("urllib.request.build_opener") + def test_get_current_ip_missing_key(self, mock_build_opener): + mock_resp = MagicMock() + mock_resp.read.return_value = b'{"status": "ok"}' + mock_opener = MagicMock() + mock_opener.open.return_value = mock_resp + mock_build_opener.return_value = mock_opener + + ip, err = api.get_current_ip() + + assert ip is None + assert err == "No IP key in response" + + @patch("urllib.request.build_opener") + def test_get_current_ip_network_error(self, mock_build_opener): + mock_opener = MagicMock() + mock_opener.open.side_effect = urllib.error.URLError("Connection refused") + mock_build_opener.return_value = mock_opener + + ip, err = api.get_current_ip() + + assert ip is None + assert "Connection refused" in err + + +@pytest.fixture(scope="module") +def api_server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), api.APIHandler) + port = server.server_port + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{port}" + server.shutdown() + server.server_close() + + +@pytest.mark.unit +class TestAPIEndpoints: + def test_status_when_network_disabled(self, api_server): + with patch("api.send_tor_command") as mock_tor: + mock_tor.return_value = ("250 DisableNetwork=1", None) + req = urllib.request.urlopen(f"{api_server}/status") + assert req.status == 200 + data = json.loads(req.read().decode("utf-8")) + assert data["status"] == "disconnected" + assert data["ip"] is None + + def test_status_when_bootstrapping(self, api_server): + with ( + patch("api.send_tor_command") as mock_tor, + patch("api.get_tor_bootstrap_status") as mock_boot, + ): + mock_tor.return_value = ("250 DisableNetwork=0", None) + mock_boot.return_value = ( + {"bootstrapped": False, "summary": "Connecting to guards"}, + None, + ) + + req = urllib.request.urlopen(f"{api_server}/status") + assert req.status == 200 + data = json.loads(req.read().decode("utf-8")) + assert data["status"] == "connecting" + assert "Connecting to guards" in data["detail"] + + def test_status_when_connected(self, api_server): + with ( + patch("api.send_tor_command") as mock_tor, + patch("api.get_tor_bootstrap_status") as mock_boot, + patch("api.get_current_ip") as mock_ip, + ): + mock_tor.return_value = ("250 DisableNetwork=0", None) + mock_boot.return_value = ({"bootstrapped": True, "summary": "Done"}, None) + mock_ip.return_value = ("185.220.101.5", None) + + req = urllib.request.urlopen(f"{api_server}/status") + assert req.status == 200 + data = json.loads(req.read().decode("utf-8")) + assert data["status"] == "connected" + assert data["ip"] == "185.220.101.5" + + def test_ip_endpoint_success(self, api_server): + with patch("api.get_current_ip") as mock_ip: + mock_ip.return_value = ("192.0.2.100", None) + req = urllib.request.urlopen(f"{api_server}/ip") + assert req.status == 200 + data = json.loads(req.read().decode("utf-8")) + assert data["ip"] == "192.0.2.100" + + def test_ip_endpoint_failure(self, api_server): + with patch("api.get_current_ip") as mock_ip: + mock_ip.return_value = (None, "Proxy offline") + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(f"{api_server}/ip") + assert exc_info.value.code == 503 + + def test_connect_endpoint(self, api_server): + with patch("api.send_tor_command") as mock_tor: + mock_tor.return_value = ("250 OK", None) + req = urllib.request.Request(f"{api_server}/connect", method="POST") + with urllib.request.urlopen(req) as resp: + assert resp.status == 200 + data = json.loads(resp.read().decode("utf-8")) + assert data["action"] == "connect" + assert data["status"] == "success" + mock_tor.assert_called_with("SETCONF DisableNetwork=0") + + def test_disconnect_endpoint(self, api_server): + api._cached_ip = "1.2.3.4" + api._cached_ip_time = time.time() + + with patch("api.send_tor_command") as mock_tor: + mock_tor.return_value = ("250 OK", None) + req = urllib.request.Request(f"{api_server}/disconnect", method="POST") + with urllib.request.urlopen(req) as resp: + assert resp.status == 200 + data = json.loads(resp.read().decode("utf-8")) + assert data["action"] == "disconnect" + assert api._cached_ip is None + + def test_reconnect_endpoint(self, api_server): + api._cached_ip = "1.2.3.4" + api._cached_ip_time = time.time() + + with patch("api.send_tor_command") as mock_tor: + mock_tor.return_value = ("250 OK", None) + req = urllib.request.Request(f"{api_server}/reconnect", method="POST") + with urllib.request.urlopen(req) as resp: + assert resp.status == 200 + data = json.loads(resp.read().decode("utf-8")) + assert data["action"] == "reconnect" + assert api._cached_ip is None + mock_tor.assert_called_with("SIGNAL NEWNYM") + + def test_not_found_endpoint(self, api_server): + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(f"{api_server}/invalid-path") + assert exc_info.value.code == 404 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..20302d1 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,44 @@ +import os +import subprocess + +import pytest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +DOCKER_DIR = os.path.join(REPO_ROOT, "docker") + + +@pytest.mark.unit +class TestConfigTemplates: + def test_privoxy_template_has_dynamic_socks_port(self): + template_path = os.path.join(DOCKER_DIR, "privoxy.config.template") + with open(template_path, "r", encoding="utf-8") as f: + content = f.read() + + msg = "privoxy.config.template must use __TORPROXY_SOCKS_PORT__ placeholder" + assert "__TORPROXY_SOCKS_PORT__" in content, msg + assert "forward-socks5t / 127.0.0.1:__TORPROXY_SOCKS_PORT__ ." in content + assert "forward-socks5t / 127.0.0.1:9050 ." not in content + + def test_torrc_template_has_dynamic_socks_port(self): + torrc_path = os.path.join(DOCKER_DIR, "torrc") + with open(torrc_path, "r", encoding="utf-8") as f: + content = f.read() + + assert "__TORPROXY_SOCKS_PORT__" in content + assert "SocksPort 0.0.0.0:__TORPROXY_SOCKS_PORT__" in content + + def test_start_script_replaces_all_placeholders(self): + start_script = os.path.join(DOCKER_DIR, "start.sh") + with open(start_script, "r", encoding="utf-8") as f: + content = f.read() + + assert '-e "s|__TORPROXY_SOCKS_PORT__|$TORPROXY_SOCKS_PORT|"' in content + + +@pytest.mark.unit +class TestShellScriptSyntax: + @pytest.mark.parametrize("script", ["start.sh", "healthcheck.sh"]) + def test_bash_syntax_valid(self, script): + script_path = os.path.join(DOCKER_DIR, script) + res = subprocess.run(["bash", "-n", script_path], capture_output=True, text=True) + assert res.returncode == 0, f"Bash syntax check failed for {script}: {res.stderr}"