From ca93d69209962b1c65495b8fd52951fb9ab5d59a Mon Sep 17 00:00:00 2001 From: Franz Haberfellner <837072+hapheus@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:08:35 +0200 Subject: [PATCH 1/6] feat(ci): add PR gate and daily verification workflows, fix SOCKS port and cleanup repo --- .envrc | 1 + .../workflows/daily-image-verification.yml | 237 ++++++++++++++ .github/workflows/pr-gate.yml | 128 ++++++++ .gitignore | 54 ++- .pre-commit-config.yaml | 20 ++ devenv.nix | 20 ++ devenv.yaml | 3 + docker/.dockerignore | 6 + docker/__pycache__/api.cpython-310.pyc | Bin 5306 -> 0 bytes docker/api.py | 119 ++++--- docker/privoxy.config.template | 2 +- docker/start.sh | 1 + pyproject.toml | 34 ++ tests/__init__.py | 0 tests/conftest.py | 7 + tests/unit/__init__.py | 0 tests/unit/test_api.py | 308 ++++++++++++++++++ tests/unit/test_config.py | 44 +++ 18 files changed, 931 insertions(+), 53 deletions(-) create mode 100644 .envrc create mode 100644 .github/workflows/daily-image-verification.yml create mode 100644 .github/workflows/pr-gate.yml create mode 100644 .pre-commit-config.yaml create mode 100644 devenv.nix create mode 100644 devenv.yaml delete mode 100644 docker/__pycache__/api.cpython-310.pyc create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_api.py create mode 100644 tests/unit/test_config.py 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..a1a5b6b --- /dev/null +++ b/.github/workflows/daily-image-verification.yml @@ -0,0 +1,237 @@ +name: "Daily Live Image Verification" + +on: + schedule: + # Täglich um 04:00 Uhr UTC (vor Beginn des europäischen Arbeitstages) + - 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. Host-IP ermitteln (für 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. Aktuelles Docker-Image pullen und starten + # ------------------------------------------------------------- + - 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-Prüfung: 100% Tor-Bootstrap abwarten (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 "{}") + IS_BOOTSTRAPPED=$(echo "$STATUS_JSON" | jq -r '.tor.bootstrapped // false') + STATUS=$(echo "$STATUS_JSON" | jq -r '.status // "unknown"') + PROGRESS=$(echo "$STATUS_JSON" | jq -r '.tor.bootstrap_phase // "pending"') + + echo "Attempt $i/36: Status='$STATUS', Bootstrapped='$IS_BOOTSTRAPPED', Phase='$PROGRESS'" + + if [ "$IS_BOOTSTRAPPED" = "true" ] && [ "$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- & Anonymitäts-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 ignoriert SIGNAL NEWNYM, wenn es innerhalb von 10s erneut gesendet wird + 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. Step Summary generieren + # ------------------------------------------------------------- + - 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. Container stoppen und aufräumen + # ------------------------------------------------------------- + - name: Cleanup Container + if: always() + run: | + echo "Stopping and cleaning up container..." + docker stop torproxy-canary || true + docker rm -f torproxy-canary || true + + # ------------------------------------------------------------- + # 9. Alarmierung bei Fehlschlag (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..ec145ba --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,128 @@ +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: Build Docker Image + run: | + docker build -t torproxy:smoke-test ./docker + + - 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 exec torproxy-smoke ps aux) + 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 5da21f143ba1b34a2162954e4740930dd739fe50..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5306 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 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}" From aa802584d5bb2215294f17b8943ffa7621e03143 Mon Sep 17 00:00:00 2001 From: Franz Haberfellner <837072+hapheus@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:10:30 +0200 Subject: [PATCH 2/6] fix(ci): use docker top to inspect container processes without requiring procps in image --- .github/workflows/pr-gate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index ec145ba..9f46bac 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -101,7 +101,7 @@ jobs: - name: Verify Daemon Processes run: | - PROCS=$(docker exec torproxy-smoke ps aux) + 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) From 8030142f4c6a9c21cada74a8275049d6f3107dca Mon Sep 17 00:00:00 2001 From: Franz Haberfellner <837072+hapheus@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:51:46 +0200 Subject: [PATCH 3/6] fix(ci): simplify canary bootstrap check to status == 'connected' Resolves QA edge-case finding where jq query for .tor.bootstrapped failed against api.py /status schema. --- .github/workflows/daily-image-verification.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/daily-image-verification.yml b/.github/workflows/daily-image-verification.yml index a1a5b6b..ced817e 100644 --- a/.github/workflows/daily-image-verification.yml +++ b/.github/workflows/daily-image-verification.yml @@ -63,13 +63,12 @@ jobs: BOOTSTRAPPED=false for i in {1..36}; do STATUS_JSON=$(curl -s --connect-timeout 5 http://localhost:8080/status || echo "{}") - IS_BOOTSTRAPPED=$(echo "$STATUS_JSON" | jq -r '.tor.bootstrapped // false') STATUS=$(echo "$STATUS_JSON" | jq -r '.status // "unknown"') - PROGRESS=$(echo "$STATUS_JSON" | jq -r '.tor.bootstrap_phase // "pending"') + DETAIL=$(echo "$STATUS_JSON" | jq -r '.detail // ""') - echo "Attempt $i/36: Status='$STATUS', Bootstrapped='$IS_BOOTSTRAPPED', Phase='$PROGRESS'" + echo "Attempt $i/36: Status='$STATUS', Detail='$DETAIL'" - if [ "$IS_BOOTSTRAPPED" = "true" ] && [ "$STATUS" = "connected" ]; then + if [ "$STATUS" = "connected" ]; then echo "✅ Tor successfully bootstrapped and connected!" BOOTSTRAPPED=true break From 1715c1f876d53bb1bf8415d48eacbc960e01ccd8 Mon Sep 17 00:00:00 2001 From: Franz Haberfellner <837072+hapheus@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:53:32 +0200 Subject: [PATCH 4/6] ci(pr-gate): add retry loop to docker build to handle Docker Hub 429 rate limits gracefully --- .github/workflows/pr-gate.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 9f46bac..c8d82d9 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -74,7 +74,14 @@ jobs: - name: Build Docker Image run: | - docker build -t torproxy:smoke-test ./docker + for i in {1..5}; do + echo "Attempt $i: Building Docker image..." + if docker build -t torproxy:smoke-test ./docker; then + break + fi + echo "Build failed (possibly Docker Hub rate limit 429). Retrying in 10s..." + sleep 10 + done - name: Run Container for Smoke Test id: run-container From 056c75be1a30845c1ea0e76ae308d350279de08a Mon Sep 17 00:00:00 2001 From: Franz Haberfellner <837072+hapheus@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:56:13 +0200 Subject: [PATCH 5/6] ci(pr-gate): authenticate to Docker Hub if secret is available to avoid 429 pull rate limits --- .github/workflows/pr-gate.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index c8d82d9..fe81740 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -72,16 +72,32 @@ jobs: - 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 From 481358d881bc121d2d1eb7b17825b0be879fa7f6 Mon Sep 17 00:00:00 2001 From: Franz Haberfellner <837072+hapheus@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:05:22 +0200 Subject: [PATCH 6/6] docs(ci): translate daily verification workflow comments to English --- .../workflows/daily-image-verification.yml | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/daily-image-verification.yml b/.github/workflows/daily-image-verification.yml index ced817e..2dbbd8d 100644 --- a/.github/workflows/daily-image-verification.yml +++ b/.github/workflows/daily-image-verification.yml @@ -2,7 +2,7 @@ name: "Daily Live Image Verification" on: schedule: - # Täglich um 04:00 Uhr UTC (vor Beginn des europäischen Arbeitstages) + # Daily at 04:00 UTC (before European business hours start) - cron: "0 4 * * *" workflow_dispatch: inputs: @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v4 # ------------------------------------------------------------- - # 1. Host-IP ermitteln (für Anti-Leak Assertion) + # 1. Determine Host IP (for anti-leak assertion) # ------------------------------------------------------------- - name: Determine Host IP id: host-info @@ -34,7 +34,7 @@ jobs: echo "runner_ip=$RUNNER_IP" >> "$GITHUB_OUTPUT" # ------------------------------------------------------------- - # 2. Aktuelles Docker-Image pullen und starten + # 2. Pull and start current Docker image # ------------------------------------------------------------- - name: Pull and Run torproxy Container id: run-container @@ -54,7 +54,7 @@ jobs: echo "container_id=$CONTAINER_ID" >> "$GITHUB_OUTPUT" # ------------------------------------------------------------- - # 3. Live-Prüfung: 100% Tor-Bootstrap abwarten (SLA: max. 180s) + # 3. Live check: wait for 100% Tor bootstrap (SLA: max 180s) # ------------------------------------------------------------- - name: Wait for Tor 100% Bootstrap timeout-minutes: 4 @@ -84,7 +84,7 @@ jobs: fi # ------------------------------------------------------------- - # 4. HTTP-Proxy (Privoxy :8118) Egress- & Anonymitäts-Check + # 4. HTTP proxy (Privoxy :8118) egress & anonymity check # ------------------------------------------------------------- - name: Verify HTTP Proxy (Port 8118) Egress & Anonymity run: | @@ -111,7 +111,7 @@ jobs: echo "initial_http_ip=$EXIT_IP" >> "$GITHUB_ENV" # ------------------------------------------------------------- - # 5. SOCKS5-Proxy (Tor :9050) Egress- & Remote-DNS-Check + # 5. SOCKS5 proxy (Tor :9050) egress & remote DNS check # ------------------------------------------------------------- - name: Verify SOCKS5 Proxy (Port 9050) Egress & DNS Resolution run: | @@ -137,7 +137,7 @@ jobs: echo "✅ SOCKS5 Proxy OK: Exit-IP is $EXIT_IP (IsTor=true)" # ------------------------------------------------------------- - # 6. Circuit-Rotation (/reconnect -> SIGNAL NEWNYM) + # 6. Circuit rotation (/reconnect -> SIGNAL NEWNYM) # ------------------------------------------------------------- - name: Verify Circuit Rotation (/reconnect -> SIGNAL NEWNYM) run: | @@ -148,7 +148,7 @@ jobs: RECONNECT_RESP=$(curl -s -X POST http://localhost:8080/reconnect) echo "Reconnect Response: $RECONNECT_RESP" - # Tor ignoriert SIGNAL NEWNYM, wenn es innerhalb von 10s erneut gesendet wird + # Tor rate-limits SIGNAL NEWNYM if sent within 10s of a previous signal echo "Waiting 15 seconds for new Tor circuit establishment..." sleep 15 @@ -173,7 +173,7 @@ jobs: fi # ------------------------------------------------------------- - # 7. Step Summary generieren + # 7. Generate step summary # ------------------------------------------------------------- - name: Generate Step Summary if: always() @@ -185,7 +185,7 @@ jobs: echo "- **Result:** ${{ job.status == 'success' && '✅ PASSED' || '❌ FAILED' }}" >> "$GITHUB_STEP_SUMMARY" # ------------------------------------------------------------- - # 8. Container stoppen und aufräumen + # 8. Stop and clean up container # ------------------------------------------------------------- - name: Cleanup Container if: always() @@ -195,7 +195,7 @@ jobs: docker rm -f torproxy-canary || true # ------------------------------------------------------------- - # 9. Alarmierung bei Fehlschlag (GitHub Issue Automation) + # 9. Alert on failure (GitHub issue automation) # ------------------------------------------------------------- - name: Alert on Failure (Create/Update Issue) if: failure()