From 6bfab589619657c3b217e80958bda45dabf06c8f Mon Sep 17 00:00:00 2001 From: Franz Haberfellner Date: Sat, 7 Nov 2026 15:57:03 +0100 Subject: [PATCH 1/6] fix(geoip): separate IPv6 size limit, accept unknown codes, gate live verification before publish Fixes #116, closes #117 - Per-file GeoIP size limits: geoip 16 MiB, geoip6 32 MiB; legacy TORPROXY_GEOIP_MAX_BYTES still caps both unless TORPROXY_GEOIP6_MAX_BYTES overrides the IPv6 limit. - Accept upstream '??' unknown-origin country codes in the GeoIP payload validator. - Record auto-update outcome to geoip-status.json and expose it additively as geoip in /status via GeoIpStatus port + file adapter, so silent fallbacks are visible. - Shift live Tor verification left: reusable live-image-verification workflow now gates PRs, publish and release against a locally built image; daily canary keeps alerting against the published image as drift detector only. Includes upstream Content-Length drift guard from shipped Settings defaults. - Branch protection on main requires the live gate. --- .env.example | 3 +- .../workflows/daily-image-verification.yml | 453 +-------------- .github/workflows/live-image-verification.yml | 515 ++++++++++++++++++ .github/workflows/pr-gate.yml | 11 + .github/workflows/publish-dockerhub.yml | 15 + .github/workflows/release.yml | 14 + README.md | 2 +- docs/compatibility.md | 4 +- docs/configuration.md | 5 +- src/torproxy/adapters/geoip_status.py | 25 + src/torproxy/bootstrap.py | 5 +- src/torproxy/configuration.py | 17 +- src/torproxy/ports.py | 7 +- src/torproxy/queries.py | 29 +- src/torproxy/runtime/geoip.py | 78 ++- src/torproxy/service.py | 14 +- tests/unit/test_ci_workflows.py | 50 +- tests/unit/test_geoip.py | 175 +++++- 18 files changed, 963 insertions(+), 459 deletions(-) create mode 100644 .github/workflows/live-image-verification.yml create mode 100644 src/torproxy/adapters/geoip_status.py diff --git a/.env.example b/.env.example index 365bdc4..49cb001 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,8 @@ TORPROXY_SOCKS_PORT=9050 # TORPROXY_GEOIP_DIR=/var/lib/tor/geoip # TORPROXY_GEOIP_URL=https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip # TORPROXY_GEOIP6_URL=https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6 -# TORPROXY_GEOIP_MAX_BYTES=10485760 +# TORPROXY_GEOIP_MAX_BYTES=16777216 +# TORPROXY_GEOIP6_MAX_BYTES=33554432 # Optional: Bearer token / API key to secure the REST management API (8080). # Recommended if the management port or container network is accessible to other services. diff --git a/.github/workflows/daily-image-verification.yml b/.github/workflows/daily-image-verification.yml index 477c841..b4a7b4e 100644 --- a/.github/workflows/daily-image-verification.yml +++ b/.github/workflows/daily-image-verification.yml @@ -1,5 +1,11 @@ name: "Daily Live Image Verification" +# ISSUE-117: drift detector only. The same reusable workflow already +# gates PRs, publish and release against a locally built image, so +# this daily run verifies the *published* image against upstream +# drift (GeoIP file growth, URL moves, Tor network outages) — it is +# no longer the first detection of a broken image. + on: schedule: # Daily at 04:00 UTC (before European business hours) @@ -20,446 +26,15 @@ permissions: read-all jobs: live-verification: name: "Live Tor Canary Verification" - runs-on: ubuntu-latest - permissions: - contents: read - issues: write if: | github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') - - steps: - - name: Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # ------------------------------------------------------------- - # 1. Host baseline: determine runner IP to guard against leaks - # ------------------------------------------------------------- - - 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" - - - name: Log in to Docker Hub (Rate Limit Prevention) - if: env.DOCKERHUB_TOKEN != '' - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - with: - registry: docker.io - username: hapheus - password: ${{ secrets.DOCKERHUB_TOKEN }} - env: - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - - # ------------------------------------------------------------- - # 2. Pull and start container - # ------------------------------------------------------------- - - 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 verification: 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!" - echo "Stabilizing Tor circuit pool for 5s..." - sleep 5 - BOOTSTRAPPED=true - break - fi - sleep 5 - done - - if [ "$BOOTSTRAPPED" != "true" ]; then - echo "❌ Tor failed to bootstrap within 180s SLA window." - exit 1 - fi - - # ------------------------------------------------------------- - # 4. HTTP proxy (Privoxy :8118) egress and 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="" - for attempt in 1 2 3 4; do - echo "HTTP Proxy check attempt $attempt/4..." - RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip 2>/dev/null || true) - if [ -n "$RESP" ] && echo "$RESP" | jq -e '.IsTor' >/dev/null 2>&1; then - break - fi - sleep 3 - done - - echo "Privoxy Response: $RESP" - - IS_TOR=$(echo "$RESP" | jq -r '.IsTor // empty') - EXIT_IP=$(echo "$RESP" | jq -r '.IP // empty') - - if [ -z "$EXIT_IP" ]; then - echo "⚠️ check.torproject.org unresponsive; checking fallback IP reflection via api.ipify.org..." - EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://api.ipify.org 2>/dev/null || true) - fi - if [ -z "$EXIT_IP" ]; then - echo "⚠️ api.ipify.org unresponsive; checking tertiary fallback via icanhazip.com..." - EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) - fi - - if [ -n "$EXIT_IP" ] && [ -z "$IS_TOR" ]; then - if [ "$EXIT_IP" != "$RUNNER_IP" ]; then - echo "✅ Fallback egress verified (IP=$EXIT_IP != Host=$RUNNER_IP)" - IS_TOR="true" - fi - fi - - if [ "$EXIT_IP" = "$RUNNER_IP" ]; then - echo "❌ CRITICAL: IP Leak detected! Exit IP matches Runner Host IP ($EXIT_IP)." - exit 1 - fi - - if [ "$IS_TOR" = "false" ]; then - echo "❌ CRITICAL: check.torproject.org explicitly reported traffic did NOT exit via Tor!" - exit 1 - fi - - if [ -z "$EXIT_IP" ]; then - echo "⚠️ External IP verification services unreachable; test is inconclusive due to external network conditions." - echo "canary_inconclusive=true" >> "$GITHUB_ENV" - else - echo "✅ HTTP Proxy OK: Exit-IP is $EXIT_IP (IsTor=true, IP != Host)" - echo "initial_http_ip=$EXIT_IP" >> "$GITHUB_ENV" - fi - - # ------------------------------------------------------------- - # 5. SOCKS5 proxy (Tor :9050) egress and 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="" - for attempt in 1 2 3 4; do - echo "SOCKS5 Proxy check attempt $attempt/4..." - RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip 2>/dev/null || true) - if [ -n "$RESP" ] && echo "$RESP" | jq -e '.IsTor' >/dev/null 2>&1; then - break - fi - sleep 3 - done - - echo "SOCKS5 Response: $RESP" - - IS_TOR=$(echo "$RESP" | jq -r '.IsTor // empty') - EXIT_IP=$(echo "$RESP" | jq -r '.IP // empty') - - if [ -z "$EXIT_IP" ]; then - echo "⚠️ check.torproject.org unresponsive; checking fallback IP reflection via api.ipify.org..." - EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --socks5-hostname 127.0.0.1:9050 https://api.ipify.org 2>/dev/null || true) - fi - if [ -z "$EXIT_IP" ]; then - echo "⚠️ api.ipify.org unresponsive; checking tertiary fallback via icanhazip.com..." - EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --socks5-hostname 127.0.0.1:9050 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) - fi - - if [ -n "$EXIT_IP" ] && [ -z "$IS_TOR" ]; then - if [ "$EXIT_IP" != "$RUNNER_IP" ]; then - echo "✅ Fallback SOCKS5 egress verified (IP=$EXIT_IP != Host=$RUNNER_IP)" - IS_TOR="true" - fi - fi - - if [ "$EXIT_IP" = "$RUNNER_IP" ]; then - echo "❌ CRITICAL: SOCKS5 IP Leak detected! Exit IP matches Runner Host IP ($EXIT_IP)." - exit 1 - fi - - if [ "$IS_TOR" = "false" ]; then - echo "❌ CRITICAL: check.torproject.org explicitly reported SOCKS5 traffic did NOT exit via Tor!" - exit 1 - fi - - if [ -z "$EXIT_IP" ]; then - echo "⚠️ SOCKS5 external verification services unreachable; test is inconclusive." - echo "canary_inconclusive=true" >> "$GITHUB_ENV" - else - echo "✅ SOCKS5 Proxy OK: Exit-IP is $EXIT_IP (IsTor=true)" - fi - - # ------------------------------------------------------------- - # 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 -sS -X POST http://localhost:8080/reconnect || echo "{}") - echo "Reconnect Response: $RECONNECT_RESP" - - # Tor ignores SIGNAL NEWNYM if sent again within 10 seconds - echo "Waiting 15 seconds for new Tor circuit establishment..." - sleep 15 - - NEW_RESP="" - for attempt in 1 2 3 4; do - echo "Post-reconnect check attempt $attempt/4..." - NEW_RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip 2>/dev/null || true) - if [ -n "$NEW_RESP" ] && echo "$NEW_RESP" | jq -e '.IsTor' >/dev/null 2>&1; then - break - fi - sleep 3 - done - - echo "New Check Response: $NEW_RESP" - - IS_TOR_NEW=$(echo "$NEW_RESP" | jq -r '.IsTor // empty') - NEW_IP=$(echo "$NEW_RESP" | jq -r '.IP // empty') - - if [ -z "$NEW_IP" ]; then - NEW_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://api.ipify.org 2>/dev/null || true) - fi - if [ -z "$NEW_IP" ]; then - NEW_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) - fi - - if [ -n "$NEW_IP" ] && [ "$NEW_IP" != "${{ steps.host-info.outputs.runner_ip }}" ]; then - IS_TOR_NEW="true" - fi - - if [ "$NEW_IP" = "${{ steps.host-info.outputs.runner_ip }}" ]; then - echo "❌ CRITICAL: IP Leak detected after circuit rotation!" - exit 1 - fi - - if [ "$IS_TOR_NEW" = "false" ]; 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 - - # ------------------------------------------------------------- - # 6b. Transparent Tor Gateway Mode verification - # ------------------------------------------------------------- - - name: Verify Transparent Tor Gateway Mode - run: | - IMAGE_TAG="${{ github.event.inputs.image_tag || 'latest' }}" - IMAGE_NAME="hapheus/torproxy:${IMAGE_TAG}" - echo "Starting torproxy in Gateway Mode..." - docker run -d \ - --name torproxy-canary-gateway \ - --cap-add=NET_ADMIN \ - -e TORPROXY_GATEWAY_MODE=true \ - -p 8089:8080 \ - "$IMAGE_NAME" - - echo "Waiting for Gateway Tor bootstrap..." - for i in {1..36}; do - STATUS=$(curl -s --connect-timeout 5 http://localhost:8089/status | jq -r '.status // empty' || true) - if [ "$STATUS" = "connected" ]; then - echo "✅ Gateway Tor successfully connected!" - break - fi - sleep 5 - done - - chmod +x tests/integration/verify_gateway.sh - ./tests/integration/verify_gateway.sh torproxy-canary-gateway - docker rm -f torproxy-canary-gateway - - # ------------------------------------------------------------- - # 6c. GeoIP database availability + auto-update verification - # ------------------------------------------------------------- - - name: Verify GeoIP Database Availability & Auto-Update - run: | - echo "Checking upstream GeoIP URLs are reachable..." - GEOIP_URL="https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip" - GEOIP6_URL="https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6" - curl -fsSL --connect-timeout 10 --max-time 30 -o /dev/null "$GEOIP_URL" || { echo "❌ GeoIP (IPv4) upstream unavailable: $GEOIP_URL"; exit 1; } - curl -fsSL --connect-timeout 10 --max-time 30 -o /dev/null "$GEOIP6_URL" || { echo "❌ GeoIP (IPv6) upstream unavailable: $GEOIP6_URL"; exit 1; } - echo "✅ GeoIP upstream URLs reachable" - - IMAGE_TAG="${{ github.event.inputs.image_tag || 'latest' }}" - IMAGE_NAME="hapheus/torproxy:${IMAGE_TAG}" - echo "Starting torproxy with GeoIP auto-update + country routing..." - docker run -d \ - --name torproxy-canary-geoip \ - -e TORPROXY_GEOIP_AUTO_UPDATE=1 \ - -e TORPROXY_COUNTRY=DE \ - -p 8087:8080 \ - "$IMAGE_NAME" - - echo "Waiting for GeoIP files to be downloaded inside container..." - GEOIP_OK=false - for i in $(seq 1 30); do - if docker exec torproxy-canary-geoip sh -c 'test -s /var/lib/tor/geoip/geoip && test -s /var/lib/tor/geoip/geoip6'; then - GEOIP_OK=true - break - fi - sleep 2 - done - if [ "$GEOIP_OK" != "true" ]; then - echo "❌ GeoIP auto-update did not download databases inside container" - docker logs torproxy-canary-geoip || true - exit 1 - fi - echo "✅ GeoIP databases downloaded inside container" - - echo "Waiting for Tor bootstrap with downloaded GeoIP DB..." - BOOTSTRAPPED=false - for i in $(seq 1 36); do - STATUS=$(curl -s --connect-timeout 5 --max-time 5 http://localhost:8087/status | jq -r '.status // empty' || true) - if [ "$STATUS" = "connected" ]; then - BOOTSTRAPPED=true - echo "✅ Tor connected with downloaded GeoIP DB" - break - fi - sleep 5 - done - if [ "$BOOTSTRAPPED" != "true" ]; then - echo "❌ Tor failed to bootstrap with GeoIP auto-update enabled" - exit 1 - fi - docker rm -f torproxy-canary-geoip - - # ------------------------------------------------------------- - # 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 || 'N/A' }}\`" >> "$GITHUB_STEP_SUMMARY" - if [ "${{ env.canary_inconclusive }}" = "true" ]; then - echo "- **Result:** ⚠️ INCONCLUSIVE (External verification APIs unreachable; no IP leak detected)" >> "$GITHUB_STEP_SUMMARY" - else - echo "- **Result:** ${{ job.status == 'success' && '✅ PASSED' || '❌ FAILED' }}" >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Dump Container Logs on Failure - if: failure() - run: | - echo "=== torproxy-canary Docker Logs ===" - docker logs torproxy-canary || true - echo "=== torproxy-canary-gateway Docker Logs ===" - docker logs torproxy-canary-gateway || true - - # ------------------------------------------------------------- - # 8. Stop and clean up container - # ------------------------------------------------------------- - - name: Cleanup Container - if: always() - run: | - echo "Stopping and cleaning up container..." - docker stop torproxy-canary torproxy-canary-gateway torproxy-canary-geoip || true - docker rm torproxy-canary torproxy-canary-gateway torproxy-canary-geoip || true - - # ------------------------------------------------------------- - # 9. Failure Notification: create or update Issue - # ------------------------------------------------------------- - - name: Alert on Failure (Create/Update Issue) - if: failure() && env.canary_inconclusive != 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - 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\nThe scheduled daily verification of \`hapheus/torproxy:${{ github.event.inputs.image_tag || '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\nPlease check the workflow logs for container output and exit status.`; - - // Check if open issue already exists to avoid spamming - 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: `⚠️ Another failure was detected 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'] - }); - } - - # ------------------------------------------------------------- - # 10. Auto-resolve Open Canary Alerts on Success - # ------------------------------------------------------------- - - name: Auto-Resolve Open Canary Alerts - if: success() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const { data: issues } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - labels: ['canary-alert'] - }); - - for (const issue of issues) { - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `✅ Canary verification passed successfully in [Run #${context.runId}](${runUrl}). Automatically closing alert.` - }); - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: 'closed', - state_reason: 'completed' - }); - } + uses: ./.github/workflows/live-image-verification.yml + with: + image_ref: "hapheus/torproxy:${{ github.event.inputs.image_tag || 'latest' }}" + build_local: false + run_alerts: true + permissions: + contents: read + issues: write diff --git a/.github/workflows/live-image-verification.yml b/.github/workflows/live-image-verification.yml new file mode 100644 index 0000000..abbd870 --- /dev/null +++ b/.github/workflows/live-image-verification.yml @@ -0,0 +1,515 @@ +name: "Live Image Verification" + +# Reusable live verification (ISSUE-117). Runs the full Tor canary +# suite (bootstrap, HTTP/SOCKS egress, circuit rotation, gateway, +# GeoIP auto-update) against a locally built image or a published +# image reference. Called by: +# - pr-gate.yml (build_local: true, required before merge) +# - publish-dockerhub.yml (build_local: true, publish waits for it) +# - release.yml (build_local: true, release waits for it) +# - daily-image-verification.yml (build_local: false, drift detector +# against hapheus/torproxy:latest, only caller that opens alerts) +# The daily run is therefore no longer the first detection of a +# broken image — its role is upstream/user drift, not first alarm. + +on: + workflow_call: + inputs: + image_ref: + description: "Image reference to test (local tag when build_local, registry reference otherwise)" + required: true + type: string + build_local: + description: "Build the image from the checked-out Dockerfile instead of pulling image_ref" + required: false + type: boolean + default: false + run_alerts: + description: "Create/close canary-alert issues on failure/success (daily canary only)" + required: false + type: boolean + default: false + +permissions: read-all + +jobs: + live-verification: + name: "Live Tor Verification" + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # ------------------------------------------------------------- + # 1. Host baseline: determine runner IP to guard against leaks + # ------------------------------------------------------------- + - name: Determine Host IP + id: host-info + run: | + RUNNER_IP=$(curl -s --connect-timeout 10 --max-time 15 https://api.ipify.org || curl -s --connect-timeout 10 --max-time 15 https://icanhazip.com) + echo "Host Runner IP: $RUNNER_IP" + echo "runner_ip=$RUNNER_IP" >> "$GITHUB_OUTPUT" + + - name: Log in to Docker Hub (Rate Limit Prevention) + if: env.DOCKERHUB_TOKEN != '' + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + with: + registry: docker.io + username: hapheus + password: ${{ secrets.DOCKERHUB_TOKEN }} + env: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + + # ------------------------------------------------------------- + # 2. Build the image from source or pull the published image + # ------------------------------------------------------------- + - name: Build Image From Source + if: inputs.build_local + run: | + echo "Building Docker Image: ${{ inputs.image_ref }}..." + docker build -f docker/Dockerfile -t "${{ inputs.image_ref }}" . + + - name: Pull Published Image + if: "!inputs.build_local" + run: | + echo "Pulling Docker Image: ${{ inputs.image_ref }}..." + docker pull "${{ inputs.image_ref }}" + + - name: Start torproxy Container + id: run-container + run: | + echo "Starting Container..." + CONTAINER_ID=$(docker run -d \ + --name torproxy-canary \ + -p 8118:8118 \ + -p 9050:9050 \ + -p 8080:8080 \ + "${{ inputs.image_ref }}") + echo "container_id=$CONTAINER_ID" >> "$GITHUB_OUTPUT" + + # ------------------------------------------------------------- + # 3. Live verification: 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 --max-time 10 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!" + echo "Stabilizing Tor circuit pool for 5s..." + sleep 5 + BOOTSTRAPPED=true + break + fi + sleep 5 + done + + if [ "$BOOTSTRAPPED" != "true" ]; then + echo "❌ Tor failed to bootstrap within 180s SLA window." + exit 1 + fi + + # ------------------------------------------------------------- + # 4. HTTP proxy (Privoxy :8118) egress and 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="" + for attempt in 1 2 3 4; do + echo "HTTP Proxy check attempt $attempt/4..." + RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip 2>/dev/null || true) + if [ -n "$RESP" ] && echo "$RESP" | jq -e '.IsTor' >/dev/null 2>&1; then + break + fi + sleep 3 + done + + echo "Privoxy Response: $RESP" + + IS_TOR=$(echo "$RESP" | jq -r '.IsTor // empty') + EXIT_IP=$(echo "$RESP" | jq -r '.IP // empty') + + if [ -z "$EXIT_IP" ]; then + echo "⚠️ check.torproject.org unresponsive; checking fallback IP reflection via api.ipify.org..." + EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://api.ipify.org 2>/dev/null || true) + fi + if [ -z "$EXIT_IP" ]; then + echo "⚠️ api.ipify.org unresponsive; checking tertiary fallback via icanhazip.com..." + EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) + fi + + if [ -n "$EXIT_IP" ] && [ -z "$IS_TOR" ]; then + if [ "$EXIT_IP" != "$RUNNER_IP" ]; then + echo "✅ Fallback egress verified (IP=$EXIT_IP != Host=$RUNNER_IP)" + IS_TOR="true" + fi + fi + + if [ "$EXIT_IP" = "$RUNNER_IP" ]; then + echo "❌ CRITICAL: IP Leak detected! Exit IP matches Runner Host IP ($EXIT_IP)." + exit 1 + fi + + if [ "$IS_TOR" = "false" ]; then + echo "❌ CRITICAL: check.torproject.org explicitly reported traffic did NOT exit via Tor!" + exit 1 + fi + + if [ -z "$EXIT_IP" ]; then + echo "⚠️ External IP verification services unreachable; test is inconclusive due to external network conditions." + echo "canary_inconclusive=true" >> "$GITHUB_ENV" + else + echo "✅ HTTP Proxy OK: Exit-IP is $EXIT_IP (IsTor=true, IP != Host)" + echo "initial_http_ip=$EXIT_IP" >> "$GITHUB_ENV" + fi + + # ------------------------------------------------------------- + # 5. SOCKS5 proxy (Tor :9050) egress and 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="" + for attempt in 1 2 3 4; do + echo "SOCKS5 Proxy check attempt $attempt/4..." + RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip 2>/dev/null || true) + if [ -n "$RESP" ] && echo "$RESP" | jq -e '.IsTor' >/dev/null 2>&1; then + break + fi + sleep 3 + done + + echo "SOCKS5 Response: $RESP" + + IS_TOR=$(echo "$RESP" | jq -r '.IsTor // empty') + EXIT_IP=$(echo "$RESP" | jq -r '.IP // empty') + + if [ -z "$EXIT_IP" ]; then + echo "⚠️ check.torproject.org unresponsive; checking fallback IP reflection via api.ipify.org..." + EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --socks5-hostname 127.0.0.1:9050 https://api.ipify.org 2>/dev/null || true) + fi + if [ -z "$EXIT_IP" ]; then + echo "⚠️ api.ipify.org unresponsive; checking tertiary fallback via icanhazip.com..." + EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --socks5-hostname 127.0.0.1:9050 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) + fi + + if [ -n "$EXIT_IP" ] && [ -z "$IS_TOR" ]; then + if [ "$EXIT_IP" != "$RUNNER_IP" ]; then + echo "✅ Fallback SOCKS5 egress verified (IP=$EXIT_IP != Host=$RUNNER_IP)" + IS_TOR="true" + fi + fi + + if [ "$EXIT_IP" = "$RUNNER_IP" ]; then + echo "❌ CRITICAL: SOCKS5 IP Leak detected! Exit IP matches Runner Host IP ($EXIT_IP)." + exit 1 + fi + + if [ "$IS_TOR" = "false" ]; then + echo "❌ CRITICAL: check.torproject.org explicitly reported SOCKS5 traffic did NOT exit via Tor!" + exit 1 + fi + + if [ -z "$EXIT_IP" ]; then + echo "⚠️ SOCKS5 external verification services unreachable; test is inconclusive." + echo "canary_inconclusive=true" >> "$GITHUB_ENV" + else + echo "✅ SOCKS5 Proxy OK: Exit-IP is $EXIT_IP (IsTor=true)" + fi + + # ------------------------------------------------------------- + # 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 -sS --max-time 15 -X POST http://localhost:8080/reconnect || echo "{}") + echo "Reconnect Response: $RECONNECT_RESP" + + # Tor ignores SIGNAL NEWNYM if sent again within 10 seconds + echo "Waiting 15 seconds for new Tor circuit establishment..." + sleep 15 + + NEW_RESP="" + for attempt in 1 2 3 4; do + echo "Post-reconnect check attempt $attempt/4..." + NEW_RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip 2>/dev/null || true) + if [ -n "$NEW_RESP" ] && echo "$NEW_RESP" | jq -e '.IsTor' >/dev/null 2>&1; then + break + fi + sleep 3 + done + + echo "New Check Response: $NEW_RESP" + + IS_TOR_NEW=$(echo "$NEW_RESP" | jq -r '.IsTor // empty') + NEW_IP=$(echo "$NEW_RESP" | jq -r '.IP // empty') + + if [ -z "$NEW_IP" ]; then + NEW_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://api.ipify.org 2>/dev/null || true) + fi + if [ -z "$NEW_IP" ]; then + NEW_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) + fi + + if [ -n "$NEW_IP" ] && [ "$NEW_IP" != "${{ steps.host-info.outputs.runner_ip }}" ]; then + IS_TOR_NEW="true" + fi + + if [ "$NEW_IP" = "${{ steps.host-info.outputs.runner_ip }}" ]; then + echo "❌ CRITICAL: IP Leak detected after circuit rotation!" + exit 1 + fi + + if [ "$IS_TOR_NEW" = "false" ]; 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 + + # ------------------------------------------------------------- + # 6b. Transparent Tor Gateway Mode verification + # ------------------------------------------------------------- + - name: Verify Transparent Tor Gateway Mode + run: | + echo "Starting torproxy in Gateway Mode..." + docker run -d \ + --name torproxy-canary-gateway \ + --cap-add=NET_ADMIN \ + -e TORPROXY_GATEWAY_MODE=true \ + -p 8089:8080 \ + "${{ inputs.image_ref }}" + + echo "Waiting for Gateway Tor bootstrap..." + BOOTSTRAPPED=false + for i in {1..36}; do + STATUS=$(curl -s --connect-timeout 5 --max-time 10 http://localhost:8089/status | jq -r '.status // empty' || true) + if [ "$STATUS" = "connected" ]; then + echo "✅ Gateway Tor successfully connected!" + BOOTSTRAPPED=true + break + fi + sleep 5 + done + if [ "$BOOTSTRAPPED" != "true" ]; then + echo "❌ Gateway Tor did not bootstrap within 180s." + docker logs torproxy-canary-gateway || true + exit 1 + fi + + chmod +x tests/integration/verify_gateway.sh + ./tests/integration/verify_gateway.sh torproxy-canary-gateway + docker rm -f torproxy-canary-gateway + + # ------------------------------------------------------------- + # 6c. GeoIP upstream drift guard + auto-update verification + # ------------------------------------------------------------- + - name: Verify GeoIP Upstream Size Drift Guard + run: | + # ISSUE-117: fail fast when an upstream GeoIP file outgrows + # its configured size limit (deterministic, no Tor needed). + # The limits come from the shipped defaults, not from a copy + # of the numbers in this workflow. + read GEOIP_URL GEOIP6_URL GEOIP_MAX GEOIP6_MAX < <(PYTHONPATH=src python3 -c "from torproxy.configuration import Settings; s=Settings.from_mapping({}); print(s.geoip_url, s.geoip6_url, s.geoip_max_bytes, s.geoip6_max_bytes)") + echo "geoip: $GEOIP_URL (max $GEOIP_MAX bytes)" + echo "geoip6: $GEOIP6_URL (max $GEOIP6_MAX bytes)" + for spec in "geoip|$GEOIP_URL|$GEOIP_MAX" "geoip6|$GEOIP6_URL|$GEOIP6_MAX"; do + name="${spec%%|*}"; rest="${spec#*|}"; url="${rest%%|*}"; max="${rest##*|}" + length=$(curl -fsSI --connect-timeout 10 --max-time 30 "$url" | tr -d '\r' | awk -F': ' 'tolower($1)=="content-length"{print $2; exit}') + if [ -z "$length" ]; then + echo "❌ Could not read Content-Length for $name upstream ($url)" + exit 1 + fi + echo "$name upstream Content-Length: $length bytes (limit $max)" + if [ "$length" -gt "$max" ]; then + echo "❌ $name upstream exceeds TORPROXY_GEOIP${name#geoip}_MAX_BYTES limit ($length > $max). Raise the default limit." + exit 1 + fi + done + echo "✅ GeoIP upstream sizes fit the configured limits" + + - name: Verify GeoIP Database Availability & Auto-Update + run: | + echo "Checking upstream GeoIP URLs are reachable..." + GEOIP_URL="https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip" + GEOIP6_URL="https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6" + curl -fsSL --connect-timeout 10 --max-time 30 -o /dev/null "$GEOIP_URL" || { echo "❌ GeoIP (IPv4) upstream unavailable: $GEOIP_URL"; exit 1; } + curl -fsSL --connect-timeout 10 --max-time 30 -o /dev/null "$GEOIP6_URL" || { echo "❌ GeoIP (IPv6) upstream unavailable: $GEOIP6_URL"; exit 1; } + echo "✅ GeoIP upstream URLs reachable" + + echo "Starting torproxy with GeoIP auto-update + country routing..." + docker run -d \ + --name torproxy-canary-geoip \ + -e TORPROXY_GEOIP_AUTO_UPDATE=1 \ + -e TORPROXY_COUNTRY=DE \ + -p 8087:8080 \ + "${{ inputs.image_ref }}" + + echo "Waiting for GeoIP files to be downloaded inside container..." + GEOIP_OK=false + for i in $(seq 1 30); do + if docker exec torproxy-canary-geoip sh -c 'test -s /var/lib/tor/geoip/geoip && test -s /var/lib/tor/geoip/geoip6'; then + GEOIP_OK=true + break + fi + sleep 2 + done + if [ "$GEOIP_OK" != "true" ]; then + echo "❌ GeoIP auto-update did not download databases inside container" + docker logs torproxy-canary-geoip || true + exit 1 + fi + echo "✅ GeoIP databases downloaded inside container" + + echo "Waiting for Tor bootstrap with downloaded GeoIP DB..." + BOOTSTRAPPED=false + for i in $(seq 1 36); do + STATUS_JSON=$(curl -s --connect-timeout 5 --max-time 10 http://localhost:8087/status || echo "{}") + STATUS=$(echo "$STATUS_JSON" | jq -r '.status // empty') + if [ "$STATUS" = "connected" ]; then + BOOTSTRAPPED=true + echo "✅ Tor connected with downloaded GeoIP DB" + SOURCE=$(echo "$STATUS_JSON" | jq -r '.geoip.source // "unknown"') + if [ "$SOURCE" != "downloaded" ]; then + echo "❌ /status geoip.source is '$SOURCE', expected 'downloaded' (silent fallback!)" + exit 1 + fi + break + fi + sleep 5 + done + if [ "$BOOTSTRAPPED" != "true" ]; then + echo "❌ Tor failed to bootstrap with GeoIP auto-update enabled" + exit 1 + fi + docker rm -f torproxy-canary-geoip + + # ------------------------------------------------------------- + # 7. Generate Step Summary + # ------------------------------------------------------------- + - name: Generate Step Summary + if: always() + run: | + echo "### 🛡️ Live Image Verification Summary" >> "$GITHUB_STEP_SUMMARY" + echo "- **Image:** \`${{ inputs.image_ref }}\`" >> "$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 || 'N/A' }}\`" >> "$GITHUB_STEP_SUMMARY" + if [ "${{ env.canary_inconclusive }}" = "true" ]; then + echo "- **Result:** ⚠️ INCONCLUSIVE (External verification APIs unreachable; no IP leak detected)" >> "$GITHUB_STEP_SUMMARY" + else + echo "- **Result:** ${{ job.status == 'success' && '✅ PASSED' || '❌ FAILED' }}" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Dump Container Logs on Failure + if: failure() + run: | + echo "=== torproxy-canary Docker Logs ===" + docker logs torproxy-canary || true + echo "=== torproxy-canary-geoip Docker Logs ===" + docker logs torproxy-canary-geoip || true + + # ------------------------------------------------------------- + # 8. Stop and clean up container + # ------------------------------------------------------------- + - name: Cleanup Container + if: always() + run: | + echo "Stopping and cleaning up container..." + docker stop torproxy-canary torproxy-canary-gateway torproxy-canary-geoip || true + docker rm torproxy-canary torproxy-canary-gateway torproxy-canary-geoip || true + + # ------------------------------------------------------------- + # 9. Failure Notification: create or update Issue (daily only) + # ------------------------------------------------------------- + - name: Alert on Failure (Create/Update Issue) + if: inputs.run_alerts && failure() && env.canary_inconclusive != 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + 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\nThe scheduled daily verification of \`${{ inputs.image_ref }}\` 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\nPlease check the workflow logs for container output and exit status.`; + + // Check if open issue already exists to avoid spamming + 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: `⚠️ Another failure was detected 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'] + }); + } + + # ------------------------------------------------------------- + # 10. Auto-resolve Open Canary Alerts on Success (daily only) + # ------------------------------------------------------------- + - name: Auto-Resolve Open Canary Alerts + if: inputs.run_alerts && success() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: ['canary-alert'] + }); + + for (const issue of issues) { + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `✅ Canary verification passed successfully in [Run #${context.runId}](${runUrl}). Automatically closing alert.` + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'completed' + }); + } diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 12ce181..6beea60 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -340,3 +340,14 @@ jobs: run: | docker rm -f torproxy-smoke torproxy-hardened-test torproxy-prev torproxy-upgrade torproxy-gateway-smoke || true docker compose down -v || true + + live-image-verification: + name: "Live Image Verification (Pre-Publish Gate)" + needs: [lint-and-validate, unit-tests, docker-smoke] + uses: ./.github/workflows/live-image-verification.yml + with: + image_ref: "torproxy:smoke-test" + build_local: true + run_alerts: false + permissions: + contents: read diff --git a/.github/workflows/publish-dockerhub.yml b/.github/workflows/publish-dockerhub.yml index 8518223..661de29 100644 --- a/.github/workflows/publish-dockerhub.yml +++ b/.github/workflows/publish-dockerhub.yml @@ -11,7 +11,22 @@ permissions: contents: read jobs: + # ISSUE-117: never publish an image that has not passed the live + # Tor verification (bootstrap, egress, gateway, GeoIP) against a + # build from the same commit. Previously the image was published + # first and only checked afterwards by the daily canary. + verify-live: + name: "Live Image Verification (Pre-Publish Gate)" + uses: ./.github/workflows/live-image-verification.yml + with: + image_ref: "torproxy:smoke-test" + build_local: true + run_alerts: false + permissions: + contents: read + publish: + needs: verify-live runs-on: ubuntu-latest steps: - name: Check out repository diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82c71dc..a4f6738 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,9 @@ name: Release # and reviewed via release PRs on release branches before tagging. # - This workflow generates the release-specific notes deterministically using # `git-cliff` and publishes the official GitHub Release for the tag. +# - ISSUE-117: the GitHub Release is only created after the same live +# Tor verification that gates publishing has passed for this tag, so a +# broken image can no longer ship as an official release. on: push: @@ -19,8 +22,19 @@ permissions: contents: read jobs: + verify-live: + name: "Live Image Verification (Pre-Release Gate)" + uses: ./.github/workflows/live-image-verification.yml + with: + image_ref: "torproxy:smoke-test" + build_local: true + run_alerts: false + permissions: + contents: read + github-release: name: "Generate Changelog & Publish GitHub Release" + needs: verify-live runs-on: ubuntu-latest permissions: contents: write diff --git a/README.md b/README.md index faa8ec3..bbea110 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ By default `torproxy` uses the Tor GeoIP database bundled with the Alpine Tor pa TORPROXY_GEOIP_AUTO_UPDATE=1 TORPROXY_COUNTRY=DE docker compose up -d ``` -With `TORPROXY_GEOIP_AUTO_UPDATE=1` the container downloads `geoip`/`geoip6` from Tor Project GitLab (HTTPS) at startup and then every `TORPROXY_GEOIP_UPDATE_INTERVAL` seconds (default `86400` = 24h), validates the format, installs the files atomically into `TORPROXY_GEOIP_DIR` (default `/var/lib/tor/geoip`), and sends Tor `SIGHUP` to reload them. If the download or validation fails (e.g. offline or upstream unavailable), the previously installed — or bundled — database keeps working, so an old version never breaks country routing. The Daily Live Image Verification workflow checks daily that the GeoIP URLs are reachable and that a container with auto-update enabled downloads the database. Additional settings: `TORPROXY_GEOIP_URL`, `TORPROXY_GEOIP6_URL`, `TORPROXY_GEOIP_MAX_BYTES`. +With `TORPROXY_GEOIP_AUTO_UPDATE=1` the container downloads `geoip`/`geoip6` from Tor Project GitLab (HTTPS) at startup and then every `TORPROXY_GEOIP_UPDATE_INTERVAL` seconds (default `86400` = 24h), validates the format, installs the files atomically into `TORPROXY_GEOIP_DIR` (default `/var/lib/tor/geoip`), and sends Tor `SIGHUP` to reload them. If the download or validation fails (e.g. offline or upstream unavailable), the previously installed — or bundled — database keeps working, so an old version never breaks country routing. Each attempt is recorded and exposed additively as `geoip` in `/status`, so a silent fallback is visible. The pre-publish Live Image Verification gate checks that the GeoIP URLs are reachable and that a container with auto-update enabled downloads the database; the Daily Live Image Verification workflow repeats this as a drift detector. Additional settings: `TORPROXY_GEOIP_URL`, `TORPROXY_GEOIP6_URL`, `TORPROXY_GEOIP_MAX_BYTES`, `TORPROXY_GEOIP6_MAX_BYTES`. --- diff --git a/docs/compatibility.md b/docs/compatibility.md index 686452c..e7ba02f 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -44,6 +44,8 @@ This document defines the strict, non-negotiable backward compatibility contract | `TORPROXY_COUNTRY` | *(unset)* | Comma-separated ISO codes | Optional: Restricts Tor exit nodes by country. Default: standard unrestricted Tor routing. | | `TORPROXY_STRICT_NODES` | `0` | Boolean (`0`, `1`, `true`, `false`) | Optional: Refuse circuits if country exit nodes are unavailable. Default: `0`. | | `TORPROXY_GEOIP_AUTO_UPDATE` | `0` | Boolean (`0`, `1`, `true`, `false`, `yes`, `no`) | Optional additive: Periodically download Tor GeoIP DB from Tor Project GitLab. Default: `0` (bundled DB, unchanged behavior). | +| `TORPROXY_GEOIP_MAX_BYTES` | `16777216` | Positive integer | Optional additive: Max accepted IPv4 GeoIP payload; also caps IPv6 unless `TORPROXY_GEOIP6_MAX_BYTES` is set (backward compatible). | +| `TORPROXY_GEOIP6_MAX_BYTES` | `33554432` | Positive integer | Optional additive: Max accepted IPv6 GeoIP payload (upstream `geoip6` ≈ 16 MB). | | `TORPROXY_API_TOKEN` | *(unset)* | String | Optional: Bearer token / API key securing REST API (8080). Default: unset (unauthenticated). | | `TORPROXY_API_ALLOW_ANONYMOUS_HEALTH` | `0` | Boolean (`0`, `1`, `true`, `false`) | Optional: Permits unauthenticated `/health` probe access when token auth is enabled. | | `TORPROXY_SOCKS_ALLOWED_CIDRS` | *(unset)* | Comma-separated CIDRs | Optional: Restricts SOCKS port access by source CIDR (Tor `SocksPolicy`). Default unset = accept all (legacy behavior). | @@ -73,7 +75,7 @@ The `status` field must strictly evaluate to one of the following canonical stri - `"connecting"`: Tor is bootstrapping or initial circuit is not yet ready. - `"disconnected"`: Network disabled via `/disconnect`. -Optional additive fields such as `detail` may be included, but `status` and `ip` must never be omitted or renamed. +Optional additive fields such as `detail` and `geoip` (GeoIP auto-update outcome: `auto_update`, `source`, `last_attempt_at`, `last_success_at`, `last_error`, `v4_bytes`, `v6_bytes`) may be included, but `status` and `ip` must never be omitted or renamed. --- diff --git a/docs/configuration.md b/docs/configuration.md index d86c3fc..6da43cd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,7 +26,8 @@ All settings have sensible defaults that allow running `torproxy` with zero conf | `TORPROXY_GEOIP_DIR` | `/var/lib/tor/geoip` | Absolute path | Directory where validated `geoip`/`geoip6` files are installed atomically. | | `TORPROXY_GEOIP_URL` | Tor Project GitLab `geoip` | `https://` URL | Source URL for the IPv4 GeoIP database. | | `TORPROXY_GEOIP6_URL` | Tor Project GitLab `geoip6` | `https://` URL | Source URL for the IPv6 GeoIP database. | -| `TORPROXY_GEOIP_MAX_BYTES` | `10485760` | Positive integer | Maximum accepted size per GeoIP file; larger payloads are rejected. | +| `TORPROXY_GEOIP_MAX_BYTES` | `16777216` | Positive integer | Maximum accepted size of the IPv4 GeoIP file; larger payloads are rejected. Setting this also caps the IPv6 file (backward compatibility) unless overridden below. | +| `TORPROXY_GEOIP6_MAX_BYTES` | `33554432` | Positive integer | Maximum accepted size of the IPv6 GeoIP file (upstream `geoip6` is ~16 MB); defaults to `TORPROXY_GEOIP_MAX_BYTES` when that is set explicitly. | | `TORPROXY_SOCKS_ALLOWED_CIDRS` | *(unset)* | Comma-separated CIDRs | Optional: Restrict which source networks may use the SOCKS port (Tor `SocksPolicy` — accept listed CIDRs plus loopback, reject all else). Default unset = accept all (legacy behavior). Note: Tor's SocksPort is unauthenticated by design; client username/password is only stream isolation, not a credential check. | --- @@ -120,3 +121,5 @@ Dynamic changes are applied via Tor Control Protocol (`SETCONF ExitNodes=...`) a Enable `TORPROXY_GEOIP_AUTO_UPDATE=1` to download `geoip`/`geoip6` from Tor Project GitLab over HTTPS at startup and every `TORPROXY_GEOIP_UPDATE_INTERVAL` seconds (default `86400`). Downloaded files are format-validated and installed atomically into `TORPROXY_GEOIP_DIR` (default `/var/lib/tor/geoip`); Tor is then sent `SIGHUP` to reload them (`PidFile /tmp/tor.pid`). **Fallback guarantee:** if the upstream is unreachable or the payload is invalid, the previously installed database — or, on first start, the bundled `/usr/share/tor/geoip{,6}` — keeps working. The container never fails to start because of GeoIP download problems, and a working database is never overwritten by a broken payload. + +**Observability:** every attempt is recorded to `geoip-status.json` in `TORPROXY_GEOIP_DIR` and surfaced additively as the `geoip` object in `/status` (`source` = `downloaded`/`bundled`/`unknown`, `last_attempt_at`, `last_success_at`, `last_error`, `v4_bytes`, `v6_bytes`), so a silent fallback — e.g. an upstream file outgrowing its size limit — is visible without reading container logs. diff --git a/src/torproxy/adapters/geoip_status.py b/src/torproxy/adapters/geoip_status.py new file mode 100644 index 0000000..e7c3c5b --- /dev/null +++ b/src/torproxy/adapters/geoip_status.py @@ -0,0 +1,25 @@ +"""Filesystem adapter for the GeoIP auto-update status file (ISSUE-117). + +The GeoIP updater (``torproxy.runtime.geoip``) runs as a separate +process and records its latest outcome as JSON in the GeoIP dir. +This adapter is the only place that reads that file; core modules +receive the outcome through the ``GeoIpStatus`` port and never do I/O. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +class FileGeoIpStatus: + def __init__(self, geoip_dir: str) -> None: + self._path = Path(geoip_dir) / "geoip-status.json" + + def snapshot(self) -> dict[str, Any] | None: + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None diff --git a/src/torproxy/bootstrap.py b/src/torproxy/bootstrap.py index cd75a85..e9b5fa9 100644 --- a/src/torproxy/bootstrap.py +++ b/src/torproxy/bootstrap.py @@ -1,10 +1,11 @@ """The only composition root that selects concrete I/O adapters.""" from torproxy.adapters.clock import SystemClock +from torproxy.adapters.geoip_status import FileGeoIpStatus from torproxy.adapters.probes import SocksReadinessProbe, TorEgressProbe from torproxy.adapters.tor_control import TorControlClient from torproxy.configuration import Settings -from torproxy.ports import Clock, ControlPort, EgressProbe, SocksProbe +from torproxy.ports import Clock, ControlPort, EgressProbe, GeoIpStatus, SocksProbe from torproxy.service import ProxyService from torproxy.state import RuntimeState from torproxy.web.router import Router @@ -18,6 +19,7 @@ def create_services( egress: EgressProbe | None = None, socks: SocksProbe | None = None, clock: Clock | None = None, + geoip: GeoIpStatus | None = None, ) -> ProxyService: return ProxyService( settings, @@ -25,6 +27,7 @@ def create_services( egress or TorEgressProbe(settings.proxy_port), socks or SocksReadinessProbe(settings.socks_port), RuntimeState(settings.country, settings.strict_nodes, clock or SystemClock()), + geoip if geoip is not None else FileGeoIpStatus(settings.geoip_dir), ) diff --git a/src/torproxy/configuration.py b/src/torproxy/configuration.py index 47110bd..907c477 100644 --- a/src/torproxy/configuration.py +++ b/src/torproxy/configuration.py @@ -74,7 +74,11 @@ class Settings: geoip_dir: str = "/var/lib/tor/geoip" geoip_url: str = "https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip" geoip6_url: str = "https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6" - geoip_max_bytes: int = 10 * 1024 * 1024 + # ISSUE-117: per-file limits. The IPv4 and IPv6 upstream + # databases grow independently (geoip6 is ~16 MB, geoip ~10 MB), + # so a single shared cap silently rejected the IPv6 file forever. + geoip_max_bytes: int = 16 * 1024 * 1024 + geoip6_max_bytes: int = 32 * 1024 * 1024 # ISSUE-18c (#108): optional comma-separated CIDRs allowed to # use the SOCKS port (Tor SocksPolicy). Empty = accept all # (legacy behavior). Tor's SocksPort is unauthenticated by @@ -127,6 +131,8 @@ def __post_init__(self) -> None: raise ValueError("TORPROXY_GEOIP_UPDATE_INTERVAL must be a positive integer") if self.geoip_max_bytes < 1: raise ValueError("TORPROXY_GEOIP_MAX_BYTES must be a positive integer") + if self.geoip6_max_bytes < 1: + raise ValueError("TORPROXY_GEOIP6_MAX_BYTES must be a positive integer") if not self.geoip_dir.startswith("/"): raise ValueError("TORPROXY_GEOIP_DIR must be an absolute path") for name, url in ( @@ -224,7 +230,14 @@ def _int_setting(name: str, default: int) -> int: "TORPROXY_GEOIP6_URL", "https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6", ), - geoip_max_bytes=_int_setting("TORPROXY_GEOIP_MAX_BYTES", 10 * 1024 * 1024), + geoip_max_bytes=_int_setting("TORPROXY_GEOIP_MAX_BYTES", 16 * 1024 * 1024), + # Backward compat: the legacy single TORPROXY_GEOIP_MAX_BYTES + # still caps both files unless TORPROXY_GEOIP6_MAX_BYTES + # explicitly overrides the IPv6 limit. + geoip6_max_bytes=_int_setting( + "TORPROXY_GEOIP6_MAX_BYTES", + int(env.get("TORPROXY_GEOIP_MAX_BYTES", str(32 * 1024 * 1024))), + ), socks_allowed_cidrs=env.get("TORPROXY_SOCKS_ALLOWED_CIDRS", "").strip(), ) diff --git a/src/torproxy/ports.py b/src/torproxy/ports.py index 011464e..e5ee41c 100644 --- a/src/torproxy/ports.py +++ b/src/torproxy/ports.py @@ -1,6 +1,6 @@ """Small structural interfaces; application code does not import adapters.""" -from typing import Protocol +from typing import Any, Protocol from torproxy.models import CommandResult @@ -26,3 +26,8 @@ def monotonic(self) -> float: def time(self) -> float: """Return wall-clock epoch time in seconds.""" + + +class GeoIpStatus(Protocol): + def snapshot(self) -> dict[str, Any] | None: + """Return the latest GeoIP auto-update outcome, or None if unknown.""" diff --git a/src/torproxy/queries.py b/src/torproxy/queries.py index 0f7e415..9cc1ae8 100644 --- a/src/torproxy/queries.py +++ b/src/torproxy/queries.py @@ -17,12 +17,35 @@ class ProxyQueries: def __init__(self, service: ProxyService) -> None: self.service = service + def _geoip_info(self) -> dict[str, Any]: + # ISSUE-117: additive observability so a silent fallback to + # the bundled GeoIP DB (e.g. upstream file outgrew the size + # limit) is visible in /status instead of only in logs. + info: dict[str, Any] = { + "auto_update": self.service.settings.geoip_auto_update, + "source": "unknown", + "last_attempt_at": None, + "last_success_at": None, + "last_error": None, + } + snapshot = self.service.geoip_snapshot() + if snapshot: + for key in ("source", "last_attempt_at", "last_success_at", "last_error"): + if key in snapshot: + info[key] = snapshot[key] + for key in ("v4_bytes", "v6_bytes"): + if isinstance(snapshot.get(key), int): + info[key] = snapshot[key] + return info + def status(self) -> Response: response, error = self.service.send_tor_command("GETCONF DisableNetwork") if error: return Response(500, {"error": f"Failed to query Tor: {error}"}) if "DisableNetwork=0" not in (response or ""): - return Response(200, {"status": "disconnected", "ip": None}) + return Response( + 200, {"status": "disconnected", "ip": None, "geoip": self._geoip_info()} + ) bootstrap, error = self.service.get_tor_bootstrap_status() if not bootstrap or not bootstrap["bootstrapped"]: detail = bootstrap["summary"] if bootstrap else error or "Unknown bootstrap status" @@ -32,17 +55,19 @@ def status(self) -> Response: "status": "connecting", "ip": None, "detail": f"Tor bootstrapping: {detail}", + "geoip": self._geoip_info(), }, ) ip, error = self.service.get_current_ip() if ip: - return Response(200, {"status": "connected", "ip": ip}) + return Response(200, {"status": "connected", "ip": ip, "geoip": self._geoip_info()}) return Response( 200, { "status": "connecting", "ip": None, "detail": f"Failed to route traffic (Tor bootstrapping?): {error}", + "geoip": self._geoip_info(), }, ) diff --git a/src/torproxy/runtime/geoip.py b/src/torproxy/runtime/geoip.py index 58a7f67..7b47176 100644 --- a/src/torproxy/runtime/geoip.py +++ b/src/torproxy/runtime/geoip.py @@ -9,6 +9,7 @@ from __future__ import annotations +import json import os import re import signal @@ -22,8 +23,9 @@ Fetcher = Callable[[str, int], bytes] -_LINE_V4 = re.compile(r"(\d{1,10}),(\d{1,10}),([A-Z]{2})\Z") -_LINE_V6 = re.compile(r"([0-9A-Fa-f:]+),([0-9A-Fa-f:]+),([A-Z]{2})\Z") +# Upstream marks ranges of unknown origin with the literal "??". +_LINE_V4 = re.compile(r"(\d{1,10}),(\d{1,10}),([A-Z]{2}|\?\?)\Z") +_LINE_V6 = re.compile(r"([0-9A-Fa-f:]+),([0-9A-Fa-f:]+),([A-Z]{2}|\?\?)\Z") def validate_geoip_content(data: bytes, ipv6: bool) -> bool: @@ -58,10 +60,52 @@ def _https_fetch(url: str, max_bytes: int) -> bytes: with urllib.request.urlopen(request, timeout=30) as response: data = response.read(max_bytes + 1) if len(data) > max_bytes: - raise ValueError("GeoIP database exceeds TORPROXY_GEOIP_MAX_BYTES") + raise ValueError(f"GeoIP database exceeds limit of {max_bytes} bytes") return data +def _write_status( + settings: Settings, + *, + source: str, + last_error: str | None, + last_success_at: float | None, + v4_bytes: int | None = None, + v6_bytes: int | None = None, +) -> None: + """Record the latest auto-update outcome for /status (best-effort). + + The GeoIP updater runs as its own process, so it cannot share + memory with the API. A tiny JSON status file in the GeoIP dir + bridges the two; the API reads it via an adapter (ISSUE-117). + """ + previous_success = last_success_at + try: + previous = json.loads( + (Path(settings.geoip_dir) / "geoip-status.json").read_text(encoding="utf-8") + ) + if previous_success is None: + previous_success = previous.get("last_success_at") + except (OSError, ValueError): + pass + payload = { + "source": source, + "last_attempt_at": time.time(), + "last_success_at": previous_success, + "last_error": last_error, + "v4_bytes": v4_bytes, + "v6_bytes": v6_bytes, + } + try: + target_dir = Path(settings.geoip_dir) + target_dir.mkdir(parents=True, exist_ok=True) + tmp = target_dir / ".geoip-status.json.tmp" + tmp.write_text(json.dumps(payload), encoding="utf-8") + tmp.replace(target_dir / "geoip-status.json") + except OSError: + pass # observability must never break the fallback guarantee + + def update_geoip(settings: Settings, fetcher: Fetcher | None = None) -> bool: """Download, validate and atomically install both GeoIP files. @@ -71,12 +115,24 @@ def update_geoip(settings: Settings, fetcher: Fetcher | None = None) -> bool: fetch = fetcher or _https_fetch try: v4 = fetch(settings.geoip_url, settings.geoip_max_bytes) - v6 = fetch(settings.geoip6_url, settings.geoip_max_bytes) + v6 = fetch(settings.geoip6_url, settings.geoip6_max_bytes) except Exception as error: # network, TLS, size, URL errors -> keep old DB print(f"[geoip] download failed, keeping previous database: {error}", file=sys.stderr) + _write_status( + settings, + source="bundled" if not has_valid_geoip(settings) else "downloaded", + last_error=str(error), + last_success_at=None, + ) return False if not validate_geoip_content(v4, ipv6=False) or not validate_geoip_content(v6, ipv6=True): print("[geoip] invalid GeoIP payload, keeping previous database", file=sys.stderr) + _write_status( + settings, + source="bundled" if not has_valid_geoip(settings) else "downloaded", + last_error="invalid GeoIP payload", + last_success_at=None, + ) return False target_dir = Path(settings.geoip_dir) @@ -101,7 +157,21 @@ def update_geoip(settings: Settings, fetcher: Fetcher | None = None) -> bool: tmp.replace(target_dir / name) except OSError as error: print(f"[geoip] install failed, keeping previous database: {error}", file=sys.stderr) + _write_status( + settings, + source="bundled" if not has_valid_geoip(settings) else "downloaded", + last_error=str(error), + last_success_at=None, + ) return False + _write_status( + settings, + source="downloaded", + last_error=None, + last_success_at=time.time(), + v4_bytes=len(v4), + v6_bytes=len(v6), + ) return True diff --git a/src/torproxy/service.py b/src/torproxy/service.py index d275ae8..817bf62 100644 --- a/src/torproxy/service.py +++ b/src/torproxy/service.py @@ -6,7 +6,7 @@ from torproxy.configuration import Settings, validate_country_input, validate_strict_nodes_input from torproxy.models import BootstrapResult, CommandResult -from torproxy.ports import ControlPort, EgressProbe, SocksProbe +from torproxy.ports import ControlPort, EgressProbe, GeoIpStatus, SocksProbe from torproxy.state import RuntimeState @@ -26,12 +26,16 @@ def __init__( egress: EgressProbe, socks: SocksProbe, state: RuntimeState, + geoip: GeoIpStatus | None = None, ) -> None: self.settings = settings self.control = control self.egress = egress self.socks = socks self.state = state + # ISSUE-117: optional GeoIP auto-update outcome (separate + # process records it to a status file; adapter reads it). + self.geoip = geoip # ISSUE-10: single-flight for egress probes. The flag is only set # while one probe is in flight; concurrent callers wait on it # rather than each firing an upstream request (thundering herd). @@ -97,6 +101,14 @@ def get_tor_bootstrap_status(self) -> BootstrapResult: def check_socks5_ready(self) -> bool: return self.socks.ready() + def geoip_snapshot(self) -> dict[str, Any] | None: + if self.geoip is None: + return None + try: + return self.geoip.snapshot() + except Exception: # observability must never break /status + return None + def _command(self, command: str, message: str) -> None: _, error = self.send_tor_command(command) if error: diff --git a/tests/unit/test_ci_workflows.py b/tests/unit/test_ci_workflows.py index 4b94102..8597798 100644 --- a/tests/unit/test_ci_workflows.py +++ b/tests/unit/test_ci_workflows.py @@ -15,10 +15,10 @@ def _curl_http_lines() -> list[str]: lines: list[str] = [] - pr_gate = WORKFLOWS / "pr-gate.yml" - for line in pr_gate.read_text(encoding="utf-8").splitlines(): - if "curl" in line and "http" in line: - lines.append(line) + for workflow in ("pr-gate.yml", "live-image-verification.yml"): + for line in (WORKFLOWS / workflow).read_text(encoding="utf-8").splitlines(): + if "curl" in line and "http" in line: + lines.append(line) return lines @@ -33,3 +33,45 @@ def test_gateway_bootstrap_is_polled_inside_the_container() -> None: # switch; polling over loopback inside the container does not. assert "docker exec torproxy-gateway-smoke curl" in pr_gate assert "curl -s http://127.0.0.1:8085/status" not in pr_gate + + +def _read(name: str) -> str: + return (WORKFLOWS / name).read_text(encoding="utf-8") + + +def test_live_verification_is_a_reusable_workflow() -> None: + # ISSUE-117: the live check must run before publish/release, not + # only after it (daily workflow_run). A reusable workflow lets the + # PR gate, publish and daily canary share one implementation. + live = _read("live-image-verification.yml") + assert "workflow_call:" in live + assert "Verify GeoIP Database Availability & Auto-Update" in live + # Upstream drift guard: HEAD sizes must fit the configured limits. + assert "Content-Length" in live + assert "geoip6_max_bytes" in live + + +def test_pr_gate_runs_live_verification_before_merge() -> None: + pr_gate = _read("pr-gate.yml") + assert "live-image-verification" in pr_gate + assert "./.github/workflows/live-image-verification.yml" in pr_gate + + +def test_publish_waits_for_live_verification() -> None: + publish = _read("publish-dockerhub.yml") + assert "./.github/workflows/live-image-verification.yml" in publish + assert "needs: verify-live" in publish or "needs: [verify-live]" in publish + + +def test_release_waits_for_live_verification() -> None: + release = _read("release.yml") + assert "./.github/workflows/live-image-verification.yml" in release + assert "verify-live" in release + + +def test_daily_canary_reuses_the_shared_workflow() -> None: + daily = _read("daily-image-verification.yml") + assert "schedule:" in daily + assert "./.github/workflows/live-image-verification.yml" in daily + # The daily run verifies the published image, it must not rebuild it. + assert "build_local" in daily diff --git a/tests/unit/test_geoip.py b/tests/unit/test_geoip.py index 2ea1468..69f853d 100644 --- a/tests/unit/test_geoip.py +++ b/tests/unit/test_geoip.py @@ -68,7 +68,36 @@ def test_defaults_are_disabled_and_backward_compatible(self): assert s.geoip_dir == "/var/lib/tor/geoip" assert s.geoip_url.startswith("https://gitlab.torproject.org/") assert s.geoip6_url.startswith("https://gitlab.torproject.org/") - assert s.geoip_max_bytes == 10 * 1024 * 1024 + assert s.geoip_max_bytes == 16 * 1024 * 1024 + assert s.geoip6_max_bytes == 32 * 1024 * 1024 + + def test_defaults_cover_current_upstream_sizes_with_headroom(self): + # Regression ISSUE-117 (#116): the single 10 MiB limit could + # never accept the IPv6 upstream file (~16 MB), so the + # auto-update silently fell back to the bundled DB forever + # while the canary asserted a download inside the container. + # Recorded 2026-11-07 via HEAD on the default URLs. + upstream_v4_bytes = 9_855_249 + upstream_v6_bytes = 16_075_127 + s = Settings.from_mapping({}) + assert s.geoip_max_bytes > upstream_v4_bytes + assert s.geoip6_max_bytes > upstream_v6_bytes + + def test_legacy_single_limit_applies_to_both_files(self): + s = Settings.from_mapping({"TORPROXY_GEOIP_MAX_BYTES": "12345"}) + assert s.geoip_max_bytes == 12345 + assert s.geoip6_max_bytes == 12345 + + def test_ipv6_limit_overrides_legacy_limit(self): + s = Settings.from_mapping( + {"TORPROXY_GEOIP_MAX_BYTES": "12345", "TORPROXY_GEOIP6_MAX_BYTES": "67890"} + ) + assert s.geoip_max_bytes == 12345 + assert s.geoip6_max_bytes == 67890 + + def test_invalid_ipv6_limit_rejected(self): + with pytest.raises(ValueError, match="TORPROXY_GEOIP6_MAX_BYTES"): + Settings.from_mapping({"TORPROXY_GEOIP6_MAX_BYTES": "0"}) def test_enabled_via_env(self): s = Settings.from_mapping({"TORPROXY_GEOIP_AUTO_UPDATE": "1"}) @@ -97,6 +126,17 @@ def test_valid_ipv6_content(self): def test_invalid_country_code_rejected(self): assert validate_geoip_content(b"16777216,16777471,DEU\n", ipv6=False) is False + def test_unknown_country_code_accepted(self): + # Upstream geoip/geoip6 mark ranges of unknown origin with + # the literal code "??" (not a 2-letter ISO code). + assert validate_geoip_content(b"394349824,394350079,??\n", ipv6=False) is True + assert ( + validate_geoip_content( + b"2000:b70:25::,2000:b70:25:ffff:ffff:ffff:ffff:ffff,??\n", ipv6=True + ) + is True + ) + def test_garbage_rejected(self): assert validate_geoip_content(b"not found\n", ipv6=False) is False @@ -139,6 +179,54 @@ def test_invalid_payload_keeps_previous_good_files(self, tmp_path): assert update_geoip(s, fetcher=bad) is False assert (tmp_path / "geoip").read_bytes() == GEOIP_V4 + def test_per_file_limits_are_passed_to_the_fetcher(self, tmp_path): + s = Settings.from_mapping( + { + "TORPROXY_GEOIP_AUTO_UPDATE": "1", + "TORPROXY_GEOIP_DIR": str(tmp_path), + "TORPROXY_GEOIP_MAX_BYTES": "111", + "TORPROXY_GEOIP6_MAX_BYTES": "222", + } + ) + seen = {} + + def fetch(url: str, max_bytes: int) -> bytes: + seen[url] = max_bytes + return GEOIP_V4 if url == s.geoip_url else GEOIP_V6 + + assert update_geoip(s, fetcher=fetch) is True + assert seen == {s.geoip_url: 111, s.geoip6_url: 222} + + def test_successful_update_writes_status_file(self, tmp_path): + import json + + s = Settings.from_mapping( + {"TORPROXY_GEOIP_AUTO_UPDATE": "1", "TORPROXY_GEOIP_DIR": str(tmp_path)} + ) + assert ( + update_geoip(s, fetcher=_fetcher({s.geoip_url: GEOIP_V4, s.geoip6_url: GEOIP_V6})) + is True + ) + status = json.loads((tmp_path / "geoip-status.json").read_text(encoding="utf-8")) + assert status["source"] == "downloaded" + assert status["last_error"] is None + assert status["last_success_at"] is not None + assert status["v4_bytes"] == len(GEOIP_V4) + assert status["v6_bytes"] == len(GEOIP_V6) + + def test_failed_update_writes_status_file_with_last_error(self, tmp_path): + import json + + s = Settings.from_mapping( + {"TORPROXY_GEOIP_AUTO_UPDATE": "1", "TORPROXY_GEOIP_DIR": str(tmp_path)} + ) + assert update_geoip(s, fetcher=_fetcher(error=OSError("offline"))) is False + status = json.loads((tmp_path / "geoip-status.json").read_text(encoding="utf-8")) + assert status["source"] == "bundled" + assert status["last_error"] == "offline" + assert status["last_success_at"] is None + assert status["last_attempt_at"] is not None + def test_has_valid_geoip(self, tmp_path): s = Settings.from_mapping( {"TORPROXY_GEOIP_AUTO_UPDATE": "1", "TORPROXY_GEOIP_DIR": str(tmp_path)} @@ -182,6 +270,91 @@ def test_fallback_keeps_bundled_paths(self): assert "GeoIPv6File /usr/share/tor/geoip6" in rendered +class TestGeoipStatusObservability: + """ISSUE-117: /status must surface the GeoIP outcome additively.""" + + def _queries(self, geoip, tmp_path): + from torproxy.queries import ProxyQueries + from torproxy.service import ProxyService + from torproxy.state import RuntimeState + + class FakeControl: + def send(self, command): + if command == "GETCONF DisableNetwork": + return "DisableNetwork=0", None + return ( + "250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=100 " + 'TAG=done SUMMARY="Done"\r\n250 OK', + None, + ) + + class FakeEgress: + def resolve(self): + return "203.0.113.7", None + + class FakeSocks: + def ready(self): + return True + + class FakeClock: + def monotonic(self): + return 0.0 + + def time(self): + return 0.0 + + settings = Settings.from_mapping({"TORPROXY_GEOIP_DIR": str(tmp_path)}) + service = ProxyService( + settings, + FakeControl(), + FakeEgress(), + FakeSocks(), + RuntimeState(None, False, FakeClock()), + geoip, + ) + return ProxyQueries(service) + + def test_status_includes_geoip_snapshot(self, tmp_path): + class StubGeoIp: + def snapshot(self): + return { + "source": "downloaded", + "last_attempt_at": 123.0, + "last_success_at": 123.0, + "last_error": None, + "v4_bytes": 10, + "v6_bytes": 20, + } + + resp = self._queries(StubGeoIp(), tmp_path).status() + assert resp.status == 200 + assert resp.body["status"] == "connected" + assert resp.body["geoip"]["source"] == "downloaded" + assert resp.body["geoip"]["last_error"] is None + assert resp.body["geoip"]["v4_bytes"] == 10 + + def test_status_geoip_unknown_without_adapter(self, tmp_path): + resp = self._queries(None, tmp_path).status() + assert resp.body["geoip"]["source"] == "unknown" + assert resp.body["geoip"]["auto_update"] is False + + def test_file_adapter_reads_status_file(self, tmp_path): + import json + + from torproxy.adapters.geoip_status import FileGeoIpStatus + + payload = {"source": "bundled", "last_error": "offline", "last_success_at": None} + (tmp_path / "geoip-status.json").write_text(json.dumps(payload), encoding="utf-8") + assert FileGeoIpStatus(str(tmp_path)).snapshot() == payload + + def test_file_adapter_missing_or_corrupt_returns_none(self, tmp_path): + from torproxy.adapters.geoip_status import FileGeoIpStatus + + assert FileGeoIpStatus(str(tmp_path)).snapshot() is None + (tmp_path / "geoip-status.json").write_text("not json", encoding="utf-8") + assert FileGeoIpStatus(str(tmp_path)).snapshot() is None + + class TestGeoipLoopAndMain: def test_run_loop_updates_and_sighups_once(self, tmp_path, monkeypatch): s = Settings.from_mapping( From 823b2582329da25750d297377a330c349c8aff74 Mon Sep 17 00:00:00 2001 From: Franz Haberfellner Date: Sat, 7 Nov 2026 16:00:21 +0100 Subject: [PATCH 2/6] fix(ci): pin reusable live workflow to PR head branch in pr-gate Relative reusable-workflow paths fail on pull_request events (startup_failure, 0 jobs): GitHub resolves them against the simulated merge commit. Referencing the reusable workflow by repo@head_ref tests the PR's own version of the workflow. --- .github/workflows/pr-gate.yml | 4 +++- tests/unit/test_ci_workflows.py | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 6beea60..7bc4d6a 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -344,7 +344,9 @@ jobs: live-image-verification: name: "Live Image Verification (Pre-Publish Gate)" needs: [lint-and-validate, unit-tests, docker-smoke] - uses: ./.github/workflows/live-image-verification.yml + # Pinned to the PR head branch: a relative reusable path fails + # on pull_request events (resolved against the merge commit). + uses: hapheus/torproxy/.github/workflows/live-image-verification.yml@${{ github.head_ref }} with: image_ref: "torproxy:smoke-test" build_local: true diff --git a/tests/unit/test_ci_workflows.py b/tests/unit/test_ci_workflows.py index 8597798..4f87074 100644 --- a/tests/unit/test_ci_workflows.py +++ b/tests/unit/test_ci_workflows.py @@ -54,7 +54,13 @@ def test_live_verification_is_a_reusable_workflow() -> None: def test_pr_gate_runs_live_verification_before_merge() -> None: pr_gate = _read("pr-gate.yml") assert "live-image-verification" in pr_gate - assert "./.github/workflows/live-image-verification.yml" in pr_gate + # Relative reusable paths fail on pull_request events (GitHub + # resolves them against the simulated merge commit), so the PR + # gate pins the reusable workflow to the PR head branch. + assert ( + "hapheus/torproxy/.github/workflows/live-image-verification.yml@${{ github.head_ref }}" + in pr_gate + ) def test_publish_waits_for_live_verification() -> None: From 1755d6b1f38ce36b1acffc6d6eddba958007cca2 Mon Sep 17 00:00:00 2001 From: Franz Haberfellner Date: Sat, 7 Nov 2026 16:01:36 +0100 Subject: [PATCH 3/6] fix(ci): use composite action for live verification instead of reusable workflow The reusable-workflow form failed with startup_failure (0 jobs) on pull_request events: GitHub resolves relative reusable paths against the simulated merge commit. A composite action under .github/actions/live-verification is resolved from the checkout and works on every event (PR, push, tag, schedule). Same steps, same inputs; callers now check out first and invoke the action. --- .github/actions/live-verification/action.yml | 487 +++++++++++++++++ .../workflows/daily-image-verification.yml | 18 +- .github/workflows/live-image-verification.yml | 515 ------------------ .github/workflows/pr-gate.yml | 21 +- .github/workflows/publish-dockerhub.yml | 16 +- .github/workflows/release.yml | 16 +- tests/unit/test_ci_workflows.py | 43 +- 7 files changed, 559 insertions(+), 557 deletions(-) create mode 100644 .github/actions/live-verification/action.yml delete mode 100644 .github/workflows/live-image-verification.yml diff --git a/.github/actions/live-verification/action.yml b/.github/actions/live-verification/action.yml new file mode 100644 index 0000000..6438a84 --- /dev/null +++ b/.github/actions/live-verification/action.yml @@ -0,0 +1,487 @@ +name: Live Image Verification +description: "Full live Tor canary suite (bootstrap, egress, rotation, gateway, GeoIP) against a locally built or published torproxy image. ISSUE-117: shared by PR gate, publish, release and daily canary so breakage is caught before publish, not after." +inputs: + image_ref: + description: "Image reference to test (local tag when build_local, registry reference otherwise)" + required: true + build_local: + description: "Build the image from the checked-out Dockerfile instead of pulling image_ref ('true'/'false')" + required: false + default: "false" + run_alerts: + description: "Create/close canary-alert issues on failure/success ('true'/'false', daily canary only)" + required: false + default: "false" +runs: + using: composite + steps: + # ------------------------------------------------------------- + # 1. Host baseline: determine runner IP to guard against leaks + # ------------------------------------------------------------- + - name: Determine Host IP + id: host-info + run: | + RUNNER_IP=$(curl -s --connect-timeout 10 --max-time 15 https://api.ipify.org || curl -s --connect-timeout 10 --max-time 15 https://icanhazip.com) + echo "Host Runner IP: $RUNNER_IP" + echo "runner_ip=$RUNNER_IP" >> "$GITHUB_OUTPUT" + + - name: Log in to Docker Hub (Rate Limit Prevention) + if: env.DOCKERHUB_TOKEN != '' + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + with: + registry: docker.io + username: hapheus + password: ${{ secrets.DOCKERHUB_TOKEN }} + env: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + + # ------------------------------------------------------------- + # 2. Build the image from source or pull the published image + # ------------------------------------------------------------- + - name: Build Image From Source + if: inputs.build_local == 'true' + run: | + echo "Building Docker Image: ${{ inputs.image_ref }}..." + docker build -f docker/Dockerfile -t "${{ inputs.image_ref }}" . + + - name: Pull Published Image + if: inputs.build_local != 'true' + run: | + echo "Pulling Docker Image: ${{ inputs.image_ref }}..." + docker pull "${{ inputs.image_ref }}" + + - name: Start torproxy Container + id: run-container + run: | + echo "Starting Container..." + CONTAINER_ID=$(docker run -d \ + --name torproxy-canary \ + -p 8118:8118 \ + -p 9050:9050 \ + -p 8080:8080 \ + "${{ inputs.image_ref }}") + echo "container_id=$CONTAINER_ID" >> "$GITHUB_OUTPUT" + + # ------------------------------------------------------------- + # 3. Live verification: 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 --max-time 10 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!" + echo "Stabilizing Tor circuit pool for 5s..." + sleep 5 + BOOTSTRAPPED=true + break + fi + sleep 5 + done + + if [ "$BOOTSTRAPPED" != "true" ]; then + echo "❌ Tor failed to bootstrap within 180s SLA window." + exit 1 + fi + + # ------------------------------------------------------------- + # 4. HTTP proxy (Privoxy :8118) egress and 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="" + for attempt in 1 2 3 4; do + echo "HTTP Proxy check attempt $attempt/4..." + RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip 2>/dev/null || true) + if [ -n "$RESP" ] && echo "$RESP" | jq -e '.IsTor' >/dev/null 2>&1; then + break + fi + sleep 3 + done + + echo "Privoxy Response: $RESP" + + IS_TOR=$(echo "$RESP" | jq -r '.IsTor // empty') + EXIT_IP=$(echo "$RESP" | jq -r '.IP // empty') + + if [ -z "$EXIT_IP" ]; then + echo "⚠️ check.torproject.org unresponsive; checking fallback IP reflection via api.ipify.org..." + EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://api.ipify.org 2>/dev/null || true) + fi + if [ -z "$EXIT_IP" ]; then + echo "⚠️ api.ipify.org unresponsive; checking tertiary fallback via icanhazip.com..." + EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) + fi + + if [ -n "$EXIT_IP" ] && [ -z "$IS_TOR" ]; then + if [ "$EXIT_IP" != "$RUNNER_IP" ]; then + echo "✅ Fallback egress verified (IP=$EXIT_IP != Host=$RUNNER_IP)" + IS_TOR="true" + fi + fi + + if [ "$EXIT_IP" = "$RUNNER_IP" ]; then + echo "❌ CRITICAL: IP Leak detected! Exit IP matches Runner Host IP ($EXIT_IP)." + exit 1 + fi + + if [ "$IS_TOR" = "false" ]; then + echo "❌ CRITICAL: check.torproject.org explicitly reported traffic did NOT exit via Tor!" + exit 1 + fi + + if [ -z "$EXIT_IP" ]; then + echo "⚠️ External IP verification services unreachable; test is inconclusive due to external network conditions." + echo "canary_inconclusive=true" >> "$GITHUB_ENV" + else + echo "✅ HTTP Proxy OK: Exit-IP is $EXIT_IP (IsTor=true, IP != Host)" + echo "initial_http_ip=$EXIT_IP" >> "$GITHUB_ENV" + fi + + # ------------------------------------------------------------- + # 5. SOCKS5 proxy (Tor :9050) egress and 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="" + for attempt in 1 2 3 4; do + echo "SOCKS5 Proxy check attempt $attempt/4..." + RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip 2>/dev/null || true) + if [ -n "$RESP" ] && echo "$RESP" | jq -e '.IsTor' >/dev/null 2>&1; then + break + fi + sleep 3 + done + + echo "SOCKS5 Response: $RESP" + + IS_TOR=$(echo "$RESP" | jq -r '.IsTor // empty') + EXIT_IP=$(echo "$RESP" | jq -r '.IP // empty') + + if [ -z "$EXIT_IP" ]; then + echo "⚠️ check.torproject.org unresponsive; checking fallback IP reflection via api.ipify.org..." + EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --socks5-hostname 127.0.0.1:9050 https://api.ipify.org 2>/dev/null || true) + fi + if [ -z "$EXIT_IP" ]; then + echo "⚠️ api.ipify.org unresponsive; checking tertiary fallback via icanhazip.com..." + EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --socks5-hostname 127.0.0.1:9050 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) + fi + + if [ -n "$EXIT_IP" ] && [ -z "$IS_TOR" ]; then + if [ "$EXIT_IP" != "$RUNNER_IP" ]; then + echo "✅ Fallback SOCKS5 egress verified (IP=$EXIT_IP != Host=$RUNNER_IP)" + IS_TOR="true" + fi + fi + + if [ "$EXIT_IP" = "$RUNNER_IP" ]; then + echo "❌ CRITICAL: SOCKS5 IP Leak detected! Exit IP matches Runner Host IP ($EXIT_IP)." + exit 1 + fi + + if [ "$IS_TOR" = "false" ]; then + echo "❌ CRITICAL: check.torproject.org explicitly reported SOCKS5 traffic did NOT exit via Tor!" + exit 1 + fi + + if [ -z "$EXIT_IP" ]; then + echo "⚠️ SOCKS5 external verification services unreachable; test is inconclusive." + echo "canary_inconclusive=true" >> "$GITHUB_ENV" + else + echo "✅ SOCKS5 Proxy OK: Exit-IP is $EXIT_IP (IsTor=true)" + fi + + # ------------------------------------------------------------- + # 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 -sS --max-time 15 -X POST http://localhost:8080/reconnect || echo "{}") + echo "Reconnect Response: $RECONNECT_RESP" + + # Tor ignores SIGNAL NEWNYM if sent again within 10 seconds + echo "Waiting 15 seconds for new Tor circuit establishment..." + sleep 15 + + NEW_RESP="" + for attempt in 1 2 3 4; do + echo "Post-reconnect check attempt $attempt/4..." + NEW_RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip 2>/dev/null || true) + if [ -n "$NEW_RESP" ] && echo "$NEW_RESP" | jq -e '.IsTor' >/dev/null 2>&1; then + break + fi + sleep 3 + done + + echo "New Check Response: $NEW_RESP" + + IS_TOR_NEW=$(echo "$NEW_RESP" | jq -r '.IsTor // empty') + NEW_IP=$(echo "$NEW_RESP" | jq -r '.IP // empty') + + if [ -z "$NEW_IP" ]; then + NEW_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://api.ipify.org 2>/dev/null || true) + fi + if [ -z "$NEW_IP" ]; then + NEW_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) + fi + + if [ -n "$NEW_IP" ] && [ "$NEW_IP" != "${{ steps.host-info.outputs.runner_ip }}" ]; then + IS_TOR_NEW="true" + fi + + if [ "$NEW_IP" = "${{ steps.host-info.outputs.runner_ip }}" ]; then + echo "❌ CRITICAL: IP Leak detected after circuit rotation!" + exit 1 + fi + + if [ "$IS_TOR_NEW" = "false" ]; 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 + + # ------------------------------------------------------------- + # 6b. Transparent Tor Gateway Mode verification + # ------------------------------------------------------------- + - name: Verify Transparent Tor Gateway Mode + run: | + echo "Starting torproxy in Gateway Mode..." + docker run -d \ + --name torproxy-canary-gateway \ + --cap-add=NET_ADMIN \ + -e TORPROXY_GATEWAY_MODE=true \ + -p 8089:8080 \ + "${{ inputs.image_ref }}" + + echo "Waiting for Gateway Tor bootstrap..." + BOOTSTRAPPED=false + for i in {1..36}; do + STATUS=$(curl -s --connect-timeout 5 --max-time 10 http://localhost:8089/status | jq -r '.status // empty' || true) + if [ "$STATUS" = "connected" ]; then + echo "✅ Gateway Tor successfully connected!" + BOOTSTRAPPED=true + break + fi + sleep 5 + done + if [ "$BOOTSTRAPPED" != "true" ]; then + echo "❌ Gateway Tor did not bootstrap within 180s." + docker logs torproxy-canary-gateway || true + exit 1 + fi + + chmod +x tests/integration/verify_gateway.sh + ./tests/integration/verify_gateway.sh torproxy-canary-gateway + docker rm -f torproxy-canary-gateway + + # ------------------------------------------------------------- + # 6c. GeoIP upstream drift guard + auto-update verification + # ------------------------------------------------------------- + - name: Verify GeoIP Upstream Size Drift Guard + run: | + # ISSUE-117: fail fast when an upstream GeoIP file outgrows + # its configured size limit (deterministic, no Tor needed). + # The limits come from the shipped defaults, not from a copy + # of the numbers in this workflow. + read GEOIP_URL GEOIP6_URL GEOIP_MAX GEOIP6_MAX < <(PYTHONPATH=src python3 -c "from torproxy.configuration import Settings; s=Settings.from_mapping({}); print(s.geoip_url, s.geoip6_url, s.geoip_max_bytes, s.geoip6_max_bytes)") + echo "geoip: $GEOIP_URL (max $GEOIP_MAX bytes)" + echo "geoip6: $GEOIP6_URL (max $GEOIP6_MAX bytes)" + for spec in "geoip|$GEOIP_URL|$GEOIP_MAX" "geoip6|$GEOIP6_URL|$GEOIP6_MAX"; do + name="${spec%%|*}"; rest="${spec#*|}"; url="${rest%%|*}"; max="${rest##*|}" + length=$(curl -fsSI --connect-timeout 10 --max-time 30 "$url" | tr -d '\r' | awk -F': ' 'tolower($1)=="content-length"{print $2; exit}') + if [ -z "$length" ]; then + echo "❌ Could not read Content-Length for $name upstream ($url)" + exit 1 + fi + echo "$name upstream Content-Length: $length bytes (limit $max)" + if [ "$length" -gt "$max" ]; then + echo "❌ $name upstream exceeds TORPROXY_GEOIP${name#geoip}_MAX_BYTES limit ($length > $max). Raise the default limit." + exit 1 + fi + done + echo "✅ GeoIP upstream sizes fit the configured limits" + + - name: Verify GeoIP Database Availability & Auto-Update + run: | + echo "Checking upstream GeoIP URLs are reachable..." + GEOIP_URL="https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip" + GEOIP6_URL="https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6" + curl -fsSL --connect-timeout 10 --max-time 30 -o /dev/null "$GEOIP_URL" || { echo "❌ GeoIP (IPv4) upstream unavailable: $GEOIP_URL"; exit 1; } + curl -fsSL --connect-timeout 10 --max-time 30 -o /dev/null "$GEOIP6_URL" || { echo "❌ GeoIP (IPv6) upstream unavailable: $GEOIP6_URL"; exit 1; } + echo "✅ GeoIP upstream URLs reachable" + + echo "Starting torproxy with GeoIP auto-update + country routing..." + docker run -d \ + --name torproxy-canary-geoip \ + -e TORPROXY_GEOIP_AUTO_UPDATE=1 \ + -e TORPROXY_COUNTRY=DE \ + -p 8087:8080 \ + "${{ inputs.image_ref }}" + + echo "Waiting for GeoIP files to be downloaded inside container..." + GEOIP_OK=false + for i in $(seq 1 30); do + if docker exec torproxy-canary-geoip sh -c 'test -s /var/lib/tor/geoip/geoip && test -s /var/lib/tor/geoip/geoip6'; then + GEOIP_OK=true + break + fi + sleep 2 + done + if [ "$GEOIP_OK" != "true" ]; then + echo "❌ GeoIP auto-update did not download databases inside container" + docker logs torproxy-canary-geoip || true + exit 1 + fi + echo "✅ GeoIP databases downloaded inside container" + + echo "Waiting for Tor bootstrap with downloaded GeoIP DB..." + BOOTSTRAPPED=false + for i in $(seq 1 36); do + STATUS_JSON=$(curl -s --connect-timeout 5 --max-time 10 http://localhost:8087/status || echo "{}") + STATUS=$(echo "$STATUS_JSON" | jq -r '.status // empty') + if [ "$STATUS" = "connected" ]; then + BOOTSTRAPPED=true + echo "✅ Tor connected with downloaded GeoIP DB" + SOURCE=$(echo "$STATUS_JSON" | jq -r '.geoip.source // "unknown"') + if [ "$SOURCE" != "downloaded" ]; then + echo "❌ /status geoip.source is '$SOURCE', expected 'downloaded' (silent fallback!)" + exit 1 + fi + break + fi + sleep 5 + done + if [ "$BOOTSTRAPPED" != "true" ]; then + echo "❌ Tor failed to bootstrap with GeoIP auto-update enabled" + exit 1 + fi + docker rm -f torproxy-canary-geoip + + # ------------------------------------------------------------- + # 7. Generate Step Summary + # ------------------------------------------------------------- + - name: Generate Step Summary + if: always() + run: | + echo "### 🛡️ Live Image Verification Summary" >> "$GITHUB_STEP_SUMMARY" + echo "- **Image:** \`${{ inputs.image_ref }}\`" >> "$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 || 'N/A' }}\`" >> "$GITHUB_STEP_SUMMARY" + if [ "${{ env.canary_inconclusive }}" = "true" ]; then + echo "- **Result:** ⚠️ INCONCLUSIVE (External verification APIs unreachable; no IP leak detected)" >> "$GITHUB_STEP_SUMMARY" + else + echo "- **Result:** see job result (composite action: job.status is not available here)" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Dump Container Logs on Failure + if: failure() + run: | + echo "=== torproxy-canary Docker Logs ===" + docker logs torproxy-canary || true + echo "=== torproxy-canary-geoip Docker Logs ===" + docker logs torproxy-canary-geoip || true + + # ------------------------------------------------------------- + # 8. Stop and clean up container + # ------------------------------------------------------------- + - name: Cleanup Container + if: always() + run: | + echo "Stopping and cleaning up container..." + docker stop torproxy-canary torproxy-canary-gateway torproxy-canary-geoip || true + docker rm torproxy-canary torproxy-canary-gateway torproxy-canary-geoip || true + + # ------------------------------------------------------------- + # 9. Failure Notification: create or update Issue (daily only) + # ------------------------------------------------------------- + - name: Alert on Failure (Create/Update Issue) + if: inputs.run_alerts == 'true' && failure() && env.canary_inconclusive != 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + 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\nThe scheduled daily verification of \`${{ inputs.image_ref }}\` 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\nPlease check the workflow logs for container output and exit status.`; + + // Check if open issue already exists to avoid spamming + 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: `⚠️ Another failure was detected 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'] + }); + } + + # ------------------------------------------------------------- + # 10. Auto-resolve Open Canary Alerts on Success (daily only) + # ------------------------------------------------------------- + - name: Auto-Resolve Open Canary Alerts + if: inputs.run_alerts == 'true' && success() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: ['canary-alert'] + }); + + for (const issue of issues) { + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `✅ Canary verification passed successfully in [Run #${context.runId}](${runUrl}). Automatically closing alert.` + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'completed' + }); + } diff --git a/.github/workflows/daily-image-verification.yml b/.github/workflows/daily-image-verification.yml index b4a7b4e..d9b1a2d 100644 --- a/.github/workflows/daily-image-verification.yml +++ b/.github/workflows/daily-image-verification.yml @@ -1,6 +1,6 @@ name: "Daily Live Image Verification" -# ISSUE-117: drift detector only. The same reusable workflow already +# ISSUE-117: drift detector only. The same composite action already # gates PRs, publish and release against a locally built image, so # this daily run verifies the *published* image against upstream # drift (GeoIP file growth, URL moves, Tor network outages) — it is @@ -30,11 +30,17 @@ jobs: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') - uses: ./.github/workflows/live-image-verification.yml - with: - image_ref: "hapheus/torproxy:${{ github.event.inputs.image_tag || 'latest' }}" - build_local: false - run_alerts: true + runs-on: ubuntu-latest permissions: contents: read issues: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run Live Image Verification + uses: ./.github/actions/live-verification + with: + image_ref: "hapheus/torproxy:${{ github.event.inputs.image_tag || 'latest' }}" + build_local: "false" + run_alerts: "true" diff --git a/.github/workflows/live-image-verification.yml b/.github/workflows/live-image-verification.yml deleted file mode 100644 index abbd870..0000000 --- a/.github/workflows/live-image-verification.yml +++ /dev/null @@ -1,515 +0,0 @@ -name: "Live Image Verification" - -# Reusable live verification (ISSUE-117). Runs the full Tor canary -# suite (bootstrap, HTTP/SOCKS egress, circuit rotation, gateway, -# GeoIP auto-update) against a locally built image or a published -# image reference. Called by: -# - pr-gate.yml (build_local: true, required before merge) -# - publish-dockerhub.yml (build_local: true, publish waits for it) -# - release.yml (build_local: true, release waits for it) -# - daily-image-verification.yml (build_local: false, drift detector -# against hapheus/torproxy:latest, only caller that opens alerts) -# The daily run is therefore no longer the first detection of a -# broken image — its role is upstream/user drift, not first alarm. - -on: - workflow_call: - inputs: - image_ref: - description: "Image reference to test (local tag when build_local, registry reference otherwise)" - required: true - type: string - build_local: - description: "Build the image from the checked-out Dockerfile instead of pulling image_ref" - required: false - type: boolean - default: false - run_alerts: - description: "Create/close canary-alert issues on failure/success (daily canary only)" - required: false - type: boolean - default: false - -permissions: read-all - -jobs: - live-verification: - name: "Live Tor Verification" - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - steps: - - name: Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # ------------------------------------------------------------- - # 1. Host baseline: determine runner IP to guard against leaks - # ------------------------------------------------------------- - - name: Determine Host IP - id: host-info - run: | - RUNNER_IP=$(curl -s --connect-timeout 10 --max-time 15 https://api.ipify.org || curl -s --connect-timeout 10 --max-time 15 https://icanhazip.com) - echo "Host Runner IP: $RUNNER_IP" - echo "runner_ip=$RUNNER_IP" >> "$GITHUB_OUTPUT" - - - name: Log in to Docker Hub (Rate Limit Prevention) - if: env.DOCKERHUB_TOKEN != '' - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - with: - registry: docker.io - username: hapheus - password: ${{ secrets.DOCKERHUB_TOKEN }} - env: - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - - # ------------------------------------------------------------- - # 2. Build the image from source or pull the published image - # ------------------------------------------------------------- - - name: Build Image From Source - if: inputs.build_local - run: | - echo "Building Docker Image: ${{ inputs.image_ref }}..." - docker build -f docker/Dockerfile -t "${{ inputs.image_ref }}" . - - - name: Pull Published Image - if: "!inputs.build_local" - run: | - echo "Pulling Docker Image: ${{ inputs.image_ref }}..." - docker pull "${{ inputs.image_ref }}" - - - name: Start torproxy Container - id: run-container - run: | - echo "Starting Container..." - CONTAINER_ID=$(docker run -d \ - --name torproxy-canary \ - -p 8118:8118 \ - -p 9050:9050 \ - -p 8080:8080 \ - "${{ inputs.image_ref }}") - echo "container_id=$CONTAINER_ID" >> "$GITHUB_OUTPUT" - - # ------------------------------------------------------------- - # 3. Live verification: 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 --max-time 10 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!" - echo "Stabilizing Tor circuit pool for 5s..." - sleep 5 - BOOTSTRAPPED=true - break - fi - sleep 5 - done - - if [ "$BOOTSTRAPPED" != "true" ]; then - echo "❌ Tor failed to bootstrap within 180s SLA window." - exit 1 - fi - - # ------------------------------------------------------------- - # 4. HTTP proxy (Privoxy :8118) egress and 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="" - for attempt in 1 2 3 4; do - echo "HTTP Proxy check attempt $attempt/4..." - RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip 2>/dev/null || true) - if [ -n "$RESP" ] && echo "$RESP" | jq -e '.IsTor' >/dev/null 2>&1; then - break - fi - sleep 3 - done - - echo "Privoxy Response: $RESP" - - IS_TOR=$(echo "$RESP" | jq -r '.IsTor // empty') - EXIT_IP=$(echo "$RESP" | jq -r '.IP // empty') - - if [ -z "$EXIT_IP" ]; then - echo "⚠️ check.torproject.org unresponsive; checking fallback IP reflection via api.ipify.org..." - EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://api.ipify.org 2>/dev/null || true) - fi - if [ -z "$EXIT_IP" ]; then - echo "⚠️ api.ipify.org unresponsive; checking tertiary fallback via icanhazip.com..." - EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) - fi - - if [ -n "$EXIT_IP" ] && [ -z "$IS_TOR" ]; then - if [ "$EXIT_IP" != "$RUNNER_IP" ]; then - echo "✅ Fallback egress verified (IP=$EXIT_IP != Host=$RUNNER_IP)" - IS_TOR="true" - fi - fi - - if [ "$EXIT_IP" = "$RUNNER_IP" ]; then - echo "❌ CRITICAL: IP Leak detected! Exit IP matches Runner Host IP ($EXIT_IP)." - exit 1 - fi - - if [ "$IS_TOR" = "false" ]; then - echo "❌ CRITICAL: check.torproject.org explicitly reported traffic did NOT exit via Tor!" - exit 1 - fi - - if [ -z "$EXIT_IP" ]; then - echo "⚠️ External IP verification services unreachable; test is inconclusive due to external network conditions." - echo "canary_inconclusive=true" >> "$GITHUB_ENV" - else - echo "✅ HTTP Proxy OK: Exit-IP is $EXIT_IP (IsTor=true, IP != Host)" - echo "initial_http_ip=$EXIT_IP" >> "$GITHUB_ENV" - fi - - # ------------------------------------------------------------- - # 5. SOCKS5 proxy (Tor :9050) egress and 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="" - for attempt in 1 2 3 4; do - echo "SOCKS5 Proxy check attempt $attempt/4..." - RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip 2>/dev/null || true) - if [ -n "$RESP" ] && echo "$RESP" | jq -e '.IsTor' >/dev/null 2>&1; then - break - fi - sleep 3 - done - - echo "SOCKS5 Response: $RESP" - - IS_TOR=$(echo "$RESP" | jq -r '.IsTor // empty') - EXIT_IP=$(echo "$RESP" | jq -r '.IP // empty') - - if [ -z "$EXIT_IP" ]; then - echo "⚠️ check.torproject.org unresponsive; checking fallback IP reflection via api.ipify.org..." - EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --socks5-hostname 127.0.0.1:9050 https://api.ipify.org 2>/dev/null || true) - fi - if [ -z "$EXIT_IP" ]; then - echo "⚠️ api.ipify.org unresponsive; checking tertiary fallback via icanhazip.com..." - EXIT_IP=$(curl -sS --connect-timeout 15 --max-time 30 --socks5-hostname 127.0.0.1:9050 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) - fi - - if [ -n "$EXIT_IP" ] && [ -z "$IS_TOR" ]; then - if [ "$EXIT_IP" != "$RUNNER_IP" ]; then - echo "✅ Fallback SOCKS5 egress verified (IP=$EXIT_IP != Host=$RUNNER_IP)" - IS_TOR="true" - fi - fi - - if [ "$EXIT_IP" = "$RUNNER_IP" ]; then - echo "❌ CRITICAL: SOCKS5 IP Leak detected! Exit IP matches Runner Host IP ($EXIT_IP)." - exit 1 - fi - - if [ "$IS_TOR" = "false" ]; then - echo "❌ CRITICAL: check.torproject.org explicitly reported SOCKS5 traffic did NOT exit via Tor!" - exit 1 - fi - - if [ -z "$EXIT_IP" ]; then - echo "⚠️ SOCKS5 external verification services unreachable; test is inconclusive." - echo "canary_inconclusive=true" >> "$GITHUB_ENV" - else - echo "✅ SOCKS5 Proxy OK: Exit-IP is $EXIT_IP (IsTor=true)" - fi - - # ------------------------------------------------------------- - # 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 -sS --max-time 15 -X POST http://localhost:8080/reconnect || echo "{}") - echo "Reconnect Response: $RECONNECT_RESP" - - # Tor ignores SIGNAL NEWNYM if sent again within 10 seconds - echo "Waiting 15 seconds for new Tor circuit establishment..." - sleep 15 - - NEW_RESP="" - for attempt in 1 2 3 4; do - echo "Post-reconnect check attempt $attempt/4..." - NEW_RESP=$(curl -sS --connect-timeout 15 --max-time 30 --retry 2 --retry-all-errors --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip 2>/dev/null || true) - if [ -n "$NEW_RESP" ] && echo "$NEW_RESP" | jq -e '.IsTor' >/dev/null 2>&1; then - break - fi - sleep 3 - done - - echo "New Check Response: $NEW_RESP" - - IS_TOR_NEW=$(echo "$NEW_RESP" | jq -r '.IsTor // empty') - NEW_IP=$(echo "$NEW_RESP" | jq -r '.IP // empty') - - if [ -z "$NEW_IP" ]; then - NEW_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://api.ipify.org 2>/dev/null || true) - fi - if [ -z "$NEW_IP" ]; then - NEW_IP=$(curl -sS --connect-timeout 15 --max-time 30 --proxy http://127.0.0.1:8118 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true) - fi - - if [ -n "$NEW_IP" ] && [ "$NEW_IP" != "${{ steps.host-info.outputs.runner_ip }}" ]; then - IS_TOR_NEW="true" - fi - - if [ "$NEW_IP" = "${{ steps.host-info.outputs.runner_ip }}" ]; then - echo "❌ CRITICAL: IP Leak detected after circuit rotation!" - exit 1 - fi - - if [ "$IS_TOR_NEW" = "false" ]; 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 - - # ------------------------------------------------------------- - # 6b. Transparent Tor Gateway Mode verification - # ------------------------------------------------------------- - - name: Verify Transparent Tor Gateway Mode - run: | - echo "Starting torproxy in Gateway Mode..." - docker run -d \ - --name torproxy-canary-gateway \ - --cap-add=NET_ADMIN \ - -e TORPROXY_GATEWAY_MODE=true \ - -p 8089:8080 \ - "${{ inputs.image_ref }}" - - echo "Waiting for Gateway Tor bootstrap..." - BOOTSTRAPPED=false - for i in {1..36}; do - STATUS=$(curl -s --connect-timeout 5 --max-time 10 http://localhost:8089/status | jq -r '.status // empty' || true) - if [ "$STATUS" = "connected" ]; then - echo "✅ Gateway Tor successfully connected!" - BOOTSTRAPPED=true - break - fi - sleep 5 - done - if [ "$BOOTSTRAPPED" != "true" ]; then - echo "❌ Gateway Tor did not bootstrap within 180s." - docker logs torproxy-canary-gateway || true - exit 1 - fi - - chmod +x tests/integration/verify_gateway.sh - ./tests/integration/verify_gateway.sh torproxy-canary-gateway - docker rm -f torproxy-canary-gateway - - # ------------------------------------------------------------- - # 6c. GeoIP upstream drift guard + auto-update verification - # ------------------------------------------------------------- - - name: Verify GeoIP Upstream Size Drift Guard - run: | - # ISSUE-117: fail fast when an upstream GeoIP file outgrows - # its configured size limit (deterministic, no Tor needed). - # The limits come from the shipped defaults, not from a copy - # of the numbers in this workflow. - read GEOIP_URL GEOIP6_URL GEOIP_MAX GEOIP6_MAX < <(PYTHONPATH=src python3 -c "from torproxy.configuration import Settings; s=Settings.from_mapping({}); print(s.geoip_url, s.geoip6_url, s.geoip_max_bytes, s.geoip6_max_bytes)") - echo "geoip: $GEOIP_URL (max $GEOIP_MAX bytes)" - echo "geoip6: $GEOIP6_URL (max $GEOIP6_MAX bytes)" - for spec in "geoip|$GEOIP_URL|$GEOIP_MAX" "geoip6|$GEOIP6_URL|$GEOIP6_MAX"; do - name="${spec%%|*}"; rest="${spec#*|}"; url="${rest%%|*}"; max="${rest##*|}" - length=$(curl -fsSI --connect-timeout 10 --max-time 30 "$url" | tr -d '\r' | awk -F': ' 'tolower($1)=="content-length"{print $2; exit}') - if [ -z "$length" ]; then - echo "❌ Could not read Content-Length for $name upstream ($url)" - exit 1 - fi - echo "$name upstream Content-Length: $length bytes (limit $max)" - if [ "$length" -gt "$max" ]; then - echo "❌ $name upstream exceeds TORPROXY_GEOIP${name#geoip}_MAX_BYTES limit ($length > $max). Raise the default limit." - exit 1 - fi - done - echo "✅ GeoIP upstream sizes fit the configured limits" - - - name: Verify GeoIP Database Availability & Auto-Update - run: | - echo "Checking upstream GeoIP URLs are reachable..." - GEOIP_URL="https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip" - GEOIP6_URL="https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip6" - curl -fsSL --connect-timeout 10 --max-time 30 -o /dev/null "$GEOIP_URL" || { echo "❌ GeoIP (IPv4) upstream unavailable: $GEOIP_URL"; exit 1; } - curl -fsSL --connect-timeout 10 --max-time 30 -o /dev/null "$GEOIP6_URL" || { echo "❌ GeoIP (IPv6) upstream unavailable: $GEOIP6_URL"; exit 1; } - echo "✅ GeoIP upstream URLs reachable" - - echo "Starting torproxy with GeoIP auto-update + country routing..." - docker run -d \ - --name torproxy-canary-geoip \ - -e TORPROXY_GEOIP_AUTO_UPDATE=1 \ - -e TORPROXY_COUNTRY=DE \ - -p 8087:8080 \ - "${{ inputs.image_ref }}" - - echo "Waiting for GeoIP files to be downloaded inside container..." - GEOIP_OK=false - for i in $(seq 1 30); do - if docker exec torproxy-canary-geoip sh -c 'test -s /var/lib/tor/geoip/geoip && test -s /var/lib/tor/geoip/geoip6'; then - GEOIP_OK=true - break - fi - sleep 2 - done - if [ "$GEOIP_OK" != "true" ]; then - echo "❌ GeoIP auto-update did not download databases inside container" - docker logs torproxy-canary-geoip || true - exit 1 - fi - echo "✅ GeoIP databases downloaded inside container" - - echo "Waiting for Tor bootstrap with downloaded GeoIP DB..." - BOOTSTRAPPED=false - for i in $(seq 1 36); do - STATUS_JSON=$(curl -s --connect-timeout 5 --max-time 10 http://localhost:8087/status || echo "{}") - STATUS=$(echo "$STATUS_JSON" | jq -r '.status // empty') - if [ "$STATUS" = "connected" ]; then - BOOTSTRAPPED=true - echo "✅ Tor connected with downloaded GeoIP DB" - SOURCE=$(echo "$STATUS_JSON" | jq -r '.geoip.source // "unknown"') - if [ "$SOURCE" != "downloaded" ]; then - echo "❌ /status geoip.source is '$SOURCE', expected 'downloaded' (silent fallback!)" - exit 1 - fi - break - fi - sleep 5 - done - if [ "$BOOTSTRAPPED" != "true" ]; then - echo "❌ Tor failed to bootstrap with GeoIP auto-update enabled" - exit 1 - fi - docker rm -f torproxy-canary-geoip - - # ------------------------------------------------------------- - # 7. Generate Step Summary - # ------------------------------------------------------------- - - name: Generate Step Summary - if: always() - run: | - echo "### 🛡️ Live Image Verification Summary" >> "$GITHUB_STEP_SUMMARY" - echo "- **Image:** \`${{ inputs.image_ref }}\`" >> "$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 || 'N/A' }}\`" >> "$GITHUB_STEP_SUMMARY" - if [ "${{ env.canary_inconclusive }}" = "true" ]; then - echo "- **Result:** ⚠️ INCONCLUSIVE (External verification APIs unreachable; no IP leak detected)" >> "$GITHUB_STEP_SUMMARY" - else - echo "- **Result:** ${{ job.status == 'success' && '✅ PASSED' || '❌ FAILED' }}" >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Dump Container Logs on Failure - if: failure() - run: | - echo "=== torproxy-canary Docker Logs ===" - docker logs torproxy-canary || true - echo "=== torproxy-canary-geoip Docker Logs ===" - docker logs torproxy-canary-geoip || true - - # ------------------------------------------------------------- - # 8. Stop and clean up container - # ------------------------------------------------------------- - - name: Cleanup Container - if: always() - run: | - echo "Stopping and cleaning up container..." - docker stop torproxy-canary torproxy-canary-gateway torproxy-canary-geoip || true - docker rm torproxy-canary torproxy-canary-gateway torproxy-canary-geoip || true - - # ------------------------------------------------------------- - # 9. Failure Notification: create or update Issue (daily only) - # ------------------------------------------------------------- - - name: Alert on Failure (Create/Update Issue) - if: inputs.run_alerts && failure() && env.canary_inconclusive != 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - 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\nThe scheduled daily verification of \`${{ inputs.image_ref }}\` 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\nPlease check the workflow logs for container output and exit status.`; - - // Check if open issue already exists to avoid spamming - 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: `⚠️ Another failure was detected 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'] - }); - } - - # ------------------------------------------------------------- - # 10. Auto-resolve Open Canary Alerts on Success (daily only) - # ------------------------------------------------------------- - - name: Auto-Resolve Open Canary Alerts - if: inputs.run_alerts && success() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const { data: issues } = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - labels: ['canary-alert'] - }); - - for (const issue of issues) { - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: `✅ Canary verification passed successfully in [Run #${context.runId}](${runUrl}). Automatically closing alert.` - }); - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: 'closed', - state_reason: 'completed' - }); - } diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 7bc4d6a..e7b35b3 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -344,12 +344,19 @@ jobs: live-image-verification: name: "Live Image Verification (Pre-Publish Gate)" needs: [lint-and-validate, unit-tests, docker-smoke] - # Pinned to the PR head branch: a relative reusable path fails - # on pull_request events (resolved against the merge commit). - uses: hapheus/torproxy/.github/workflows/live-image-verification.yml@${{ github.head_ref }} - with: - image_ref: "torproxy:smoke-test" - build_local: true - run_alerts: false + runs-on: ubuntu-latest permissions: contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Composite action (not a reusable workflow): relative reusable + # paths fail on pull_request events (startup_failure, 0 jobs) + # because GitHub resolves them against the merge commit. + - name: Run Live Image Verification + uses: ./.github/actions/live-verification + with: + image_ref: "torproxy:smoke-test" + build_local: "true" + run_alerts: "false" diff --git a/.github/workflows/publish-dockerhub.yml b/.github/workflows/publish-dockerhub.yml index 661de29..0450e53 100644 --- a/.github/workflows/publish-dockerhub.yml +++ b/.github/workflows/publish-dockerhub.yml @@ -17,13 +17,19 @@ jobs: # first and only checked afterwards by the daily canary. verify-live: name: "Live Image Verification (Pre-Publish Gate)" - uses: ./.github/workflows/live-image-verification.yml - with: - image_ref: "torproxy:smoke-test" - build_local: true - run_alerts: false + runs-on: ubuntu-latest permissions: contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run Live Image Verification + uses: ./.github/actions/live-verification + with: + image_ref: "torproxy:smoke-test" + build_local: "true" + run_alerts: "false" publish: needs: verify-live diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4f6738..3b65480 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,19 @@ permissions: jobs: verify-live: name: "Live Image Verification (Pre-Release Gate)" - uses: ./.github/workflows/live-image-verification.yml - with: - image_ref: "torproxy:smoke-test" - build_local: true - run_alerts: false + runs-on: ubuntu-latest permissions: contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run Live Image Verification + uses: ./.github/actions/live-verification + with: + image_ref: "torproxy:smoke-test" + build_local: "true" + run_alerts: "false" github-release: name: "Generate Changelog & Publish GitHub Release" diff --git a/tests/unit/test_ci_workflows.py b/tests/unit/test_ci_workflows.py index 4f87074..39b7382 100644 --- a/tests/unit/test_ci_workflows.py +++ b/tests/unit/test_ci_workflows.py @@ -15,8 +15,12 @@ def _curl_http_lines() -> list[str]: lines: list[str] = [] - for workflow in ("pr-gate.yml", "live-image-verification.yml"): - for line in (WORKFLOWS / workflow).read_text(encoding="utf-8").splitlines(): + paths = [ + WORKFLOWS / "pr-gate.yml", + WORKFLOWS.parents[1] / ".github" / "actions" / "live-verification" / "action.yml", + ] + for path in paths: + for line in path.read_text(encoding="utf-8").splitlines(): if "curl" in line and "http" in line: lines.append(line) return lines @@ -39,12 +43,18 @@ def _read(name: str) -> str: return (WORKFLOWS / name).read_text(encoding="utf-8") -def test_live_verification_is_a_reusable_workflow() -> None: +def test_live_verification_is_a_composite_action() -> None: # ISSUE-117: the live check must run before publish/release, not - # only after it (daily workflow_run). A reusable workflow lets the - # PR gate, publish and daily canary share one implementation. - live = _read("live-image-verification.yml") - assert "workflow_call:" in live + # only after it (daily workflow_run). A composite action lets the + # PR gate, publish, release and daily canary share one + # implementation. (A reusable workflow was tried first, but + # relative reusable paths fail with startup_failure on + # pull_request events — GitHub resolves them against the merge + # commit — so the composite action is the reliable form.) + live = ( + WORKFLOWS.parents[1] / ".github" / "actions" / "live-verification" / "action.yml" + ).read_text(encoding="utf-8") + assert "using: composite" in live assert "Verify GeoIP Database Availability & Auto-Update" in live # Upstream drift guard: HEAD sizes must fit the configured limits. assert "Content-Length" in live @@ -54,30 +64,25 @@ def test_live_verification_is_a_reusable_workflow() -> None: def test_pr_gate_runs_live_verification_before_merge() -> None: pr_gate = _read("pr-gate.yml") assert "live-image-verification" in pr_gate - # Relative reusable paths fail on pull_request events (GitHub - # resolves them against the simulated merge commit), so the PR - # gate pins the reusable workflow to the PR head branch. - assert ( - "hapheus/torproxy/.github/workflows/live-image-verification.yml@${{ github.head_ref }}" - in pr_gate - ) + assert "uses: ./.github/actions/live-verification" in pr_gate def test_publish_waits_for_live_verification() -> None: publish = _read("publish-dockerhub.yml") - assert "./.github/workflows/live-image-verification.yml" in publish + assert "uses: ./.github/actions/live-verification" in publish assert "needs: verify-live" in publish or "needs: [verify-live]" in publish def test_release_waits_for_live_verification() -> None: release = _read("release.yml") - assert "./.github/workflows/live-image-verification.yml" in release + assert "uses: ./.github/actions/live-verification" in release assert "verify-live" in release -def test_daily_canary_reuses_the_shared_workflow() -> None: +def test_daily_canary_reuses_the_shared_action() -> None: daily = _read("daily-image-verification.yml") assert "schedule:" in daily - assert "./.github/workflows/live-image-verification.yml" in daily + assert "uses: ./.github/actions/live-verification" in daily # The daily run verifies the published image, it must not rebuild it. - assert "build_local" in daily + assert 'build_local: "false"' in daily + assert 'run_alerts: "true"' in daily From 90bdebc1f3d8167d488e3a16faf500732dce4a35 Mon Sep 17 00:00:00 2001 From: Franz Haberfellner Date: Sat, 7 Nov 2026 16:05:24 +0100 Subject: [PATCH 4/6] fix(ci): satisfy composite action constraints in live verification - shell: bash on every run step (required by composite actions) - Docker Hub token passed as input (secrets context is not available inside composite actions) - drop timeout-minutes (unsupported in composite steps; the bootstrap loop is self-bounded at 36x5s) --- .github/actions/live-verification/action.yml | 25 +++++++++++++++---- .../workflows/daily-image-verification.yml | 1 + .github/workflows/pr-gate.yml | 1 + .github/workflows/publish-dockerhub.yml | 1 + .github/workflows/release.yml | 1 + tests/unit/test_ci_workflows.py | 12 +++++++++ 6 files changed, 36 insertions(+), 5 deletions(-) diff --git a/.github/actions/live-verification/action.yml b/.github/actions/live-verification/action.yml index 6438a84..131777c 100644 --- a/.github/actions/live-verification/action.yml +++ b/.github/actions/live-verification/action.yml @@ -12,6 +12,10 @@ inputs: description: "Create/close canary-alert issues on failure/success ('true'/'false', daily canary only)" required: false default: "false" + dockerhub_token: + description: "Docker Hub token for rate-limit prevention (composite actions cannot access the secrets context)" + required: false + default: "" runs: using: composite steps: @@ -20,38 +24,40 @@ runs: # ------------------------------------------------------------- - name: Determine Host IP id: host-info + shell: bash run: | RUNNER_IP=$(curl -s --connect-timeout 10 --max-time 15 https://api.ipify.org || curl -s --connect-timeout 10 --max-time 15 https://icanhazip.com) echo "Host Runner IP: $RUNNER_IP" echo "runner_ip=$RUNNER_IP" >> "$GITHUB_OUTPUT" - name: Log in to Docker Hub (Rate Limit Prevention) - if: env.DOCKERHUB_TOKEN != '' + if: inputs.dockerhub_token != '' uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: docker.io username: hapheus - password: ${{ secrets.DOCKERHUB_TOKEN }} - env: - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + password: ${{ inputs.dockerhub_token }} # ------------------------------------------------------------- # 2. Build the image from source or pull the published image # ------------------------------------------------------------- - name: Build Image From Source if: inputs.build_local == 'true' + shell: bash run: | echo "Building Docker Image: ${{ inputs.image_ref }}..." docker build -f docker/Dockerfile -t "${{ inputs.image_ref }}" . - name: Pull Published Image if: inputs.build_local != 'true' + shell: bash run: | echo "Pulling Docker Image: ${{ inputs.image_ref }}..." docker pull "${{ inputs.image_ref }}" - name: Start torproxy Container id: run-container + shell: bash run: | echo "Starting Container..." CONTAINER_ID=$(docker run -d \ @@ -66,7 +72,7 @@ runs: # 3. Live verification: wait for 100% Tor bootstrap (SLA: max 180s) # ------------------------------------------------------------- - name: Wait for Tor 100% Bootstrap - timeout-minutes: 4 + shell: bash run: | echo "Waiting for Tor Bootstrap via REST API (http://localhost:8080/status)..." BOOTSTRAPPED=false @@ -96,6 +102,7 @@ runs: # 4. HTTP proxy (Privoxy :8118) egress and anonymity check # ------------------------------------------------------------- - name: Verify HTTP Proxy (Port 8118) Egress & Anonymity + shell: bash run: | RUNNER_IP="${{ steps.host-info.outputs.runner_ip }}" echo "Testing Privoxy HTTP Proxy via http://127.0.0.1:8118..." @@ -153,6 +160,7 @@ runs: # 5. SOCKS5 proxy (Tor :9050) egress and remote DNS check # ------------------------------------------------------------- - name: Verify SOCKS5 Proxy (Port 9050) Egress & DNS Resolution + shell: bash run: | RUNNER_IP="${{ steps.host-info.outputs.runner_ip }}" echo "Testing Tor SOCKS5 Proxy via socks5h://127.0.0.1:9050 (Remote DNS)..." @@ -209,6 +217,7 @@ runs: # 6. Circuit rotation (/reconnect -> SIGNAL NEWNYM) # ------------------------------------------------------------- - name: Verify Circuit Rotation (/reconnect -> SIGNAL NEWNYM) + shell: bash run: | OLD_IP="${{ env.initial_http_ip }}" echo "Current Exit IP before rotation: $OLD_IP" @@ -270,6 +279,7 @@ runs: # 6b. Transparent Tor Gateway Mode verification # ------------------------------------------------------------- - name: Verify Transparent Tor Gateway Mode + shell: bash run: | echo "Starting torproxy in Gateway Mode..." docker run -d \ @@ -304,6 +314,7 @@ runs: # 6c. GeoIP upstream drift guard + auto-update verification # ------------------------------------------------------------- - name: Verify GeoIP Upstream Size Drift Guard + shell: bash run: | # ISSUE-117: fail fast when an upstream GeoIP file outgrows # its configured size limit (deterministic, no Tor needed). @@ -328,6 +339,7 @@ runs: echo "✅ GeoIP upstream sizes fit the configured limits" - name: Verify GeoIP Database Availability & Auto-Update + shell: bash run: | echo "Checking upstream GeoIP URLs are reachable..." GEOIP_URL="https://gitlab.torproject.org/tpo/core/tor/-/raw/main/src/config/geoip" @@ -388,6 +400,7 @@ runs: # ------------------------------------------------------------- - name: Generate Step Summary if: always() + shell: bash run: | echo "### 🛡️ Live Image Verification Summary" >> "$GITHUB_STEP_SUMMARY" echo "- **Image:** \`${{ inputs.image_ref }}\`" >> "$GITHUB_STEP_SUMMARY" @@ -401,6 +414,7 @@ runs: - name: Dump Container Logs on Failure if: failure() + shell: bash run: | echo "=== torproxy-canary Docker Logs ===" docker logs torproxy-canary || true @@ -412,6 +426,7 @@ runs: # ------------------------------------------------------------- - name: Cleanup Container if: always() + shell: bash run: | echo "Stopping and cleaning up container..." docker stop torproxy-canary torproxy-canary-gateway torproxy-canary-geoip || true diff --git a/.github/workflows/daily-image-verification.yml b/.github/workflows/daily-image-verification.yml index d9b1a2d..be6cd46 100644 --- a/.github/workflows/daily-image-verification.yml +++ b/.github/workflows/daily-image-verification.yml @@ -44,3 +44,4 @@ jobs: image_ref: "hapheus/torproxy:${{ github.event.inputs.image_tag || 'latest' }}" build_local: "false" run_alerts: "true" + dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index e7b35b3..f22b24d 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -360,3 +360,4 @@ jobs: image_ref: "torproxy:smoke-test" build_local: "true" run_alerts: "false" + dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/publish-dockerhub.yml b/.github/workflows/publish-dockerhub.yml index 0450e53..27cdc71 100644 --- a/.github/workflows/publish-dockerhub.yml +++ b/.github/workflows/publish-dockerhub.yml @@ -30,6 +30,7 @@ jobs: image_ref: "torproxy:smoke-test" build_local: "true" run_alerts: "false" + dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} publish: needs: verify-live diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b65480..7387316 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,7 @@ jobs: image_ref: "torproxy:smoke-test" build_local: "true" run_alerts: "false" + dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} github-release: name: "Generate Changelog & Publish GitHub Release" diff --git a/tests/unit/test_ci_workflows.py b/tests/unit/test_ci_workflows.py index 39b7382..e3a39c9 100644 --- a/tests/unit/test_ci_workflows.py +++ b/tests/unit/test_ci_workflows.py @@ -61,6 +61,18 @@ def test_live_verification_is_a_composite_action() -> None: assert "geoip6_max_bytes" in live +def test_composite_action_respects_composite_constraints() -> None: + # Composite actions require shell: bash on run steps, cannot use + # the secrets context (token must be passed as an input) and do + # not support timeout-minutes on steps. + live = ( + WORKFLOWS.parents[1] / ".github" / "actions" / "live-verification" / "action.yml" + ).read_text(encoding="utf-8") + assert "secrets." not in live + assert "timeout-minutes" not in live + assert live.count("run: |") == live.count("shell: bash") + + def test_pr_gate_runs_live_verification_before_merge() -> None: pr_gate = _read("pr-gate.yml") assert "live-image-verification" in pr_gate From 86f5d48229a8c2dd32e615a5e83789664be3ff36 Mon Sep 17 00:00:00 2001 From: Franz Haberfellner Date: Sat, 7 Nov 2026 16:19:22 +0100 Subject: [PATCH 5/6] fix(ci): poll gateway bootstrap inside the container in live verification The gateway step polled the published host port through the kill switch, which never returns connected (ISSUE-19 pattern): PR #118 burned 8m50s in this step although the container's Tor bootstrapped in 8s. Poll over loopback inside the container with a 180s wall-clock cap, mirroring the PR gate fix. --- .github/actions/live-verification/action.yml | 14 +++++++++++--- tests/unit/test_ci_workflows.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/actions/live-verification/action.yml b/.github/actions/live-verification/action.yml index 131777c..50d530e 100644 --- a/.github/actions/live-verification/action.yml +++ b/.github/actions/live-verification/action.yml @@ -290,9 +290,16 @@ runs: "${{ inputs.image_ref }}" echo "Waiting for Gateway Tor bootstrap..." + # ISSUE-19/117: poll over loopback INSIDE the container. + # The published host port goes through the gateway kill + # switch, so a host-side curl never observes "connected" + # (the run in PR #118 burned 8m50s this way although Tor + # itself bootstrapped in 8s). The whole wait is wall-clock + # capped at 180s with bounded polls, like the PR gate. BOOTSTRAPPED=false - for i in {1..36}; do - STATUS=$(curl -s --connect-timeout 5 --max-time 10 http://localhost:8089/status | jq -r '.status // empty' || true) + START=$SECONDS + while [ $((SECONDS - START)) -lt 180 ]; do + STATUS=$(docker exec torproxy-canary-gateway curl -fsS --connect-timeout 2 --max-time 5 http://127.0.0.1:8080/status 2>/dev/null | jq -r '.status // empty' || true) if [ "$STATUS" = "connected" ]; then echo "✅ Gateway Tor successfully connected!" BOOTSTRAPPED=true @@ -301,8 +308,9 @@ runs: sleep 5 done if [ "$BOOTSTRAPPED" != "true" ]; then - echo "❌ Gateway Tor did not bootstrap within 180s." + echo "❌ Gateway Tor did not bootstrap within 180s; container logs:" docker logs torproxy-canary-gateway || true + docker rm -f torproxy-canary-gateway || true exit 1 fi diff --git a/tests/unit/test_ci_workflows.py b/tests/unit/test_ci_workflows.py index e3a39c9..a1dfe32 100644 --- a/tests/unit/test_ci_workflows.py +++ b/tests/unit/test_ci_workflows.py @@ -73,6 +73,18 @@ def test_composite_action_respects_composite_constraints() -> None: assert live.count("run: |") == live.count("shell: bash") +def test_gateway_bootstrap_is_polled_inside_the_container_in_live_action() -> None: + # ISSUE-19 regression (PR #118): polling the gateway container + # via its published host port goes through the kill switch and + # never returns "connected" — the step burned ~9 minutes although + # Tor bootstrapped in seconds. Poll over loopback in-container. + live = ( + WORKFLOWS.parents[1] / ".github" / "actions" / "live-verification" / "action.yml" + ).read_text(encoding="utf-8") + assert "docker exec torproxy-canary-gateway curl" in live + assert "http://localhost:8089/status" not in live + + def test_pr_gate_runs_live_verification_before_merge() -> None: pr_gate = _read("pr-gate.yml") assert "live-image-verification" in pr_gate From 5a3d9d9b2c8afa19981bd72e1a53ee5b52e43d8e Mon Sep 17 00:00:00 2001 From: Franz Haberfellner Date: Sat, 7 Nov 2026 16:26:47 +0100 Subject: [PATCH 6/6] fix(codeql): explain empty except in geoip status writer Addresses the CodeQL empty-except alert on PR #118: the except clause intentionally ignores a missing/corrupt previous status file; document that instead of a bare pass. --- src/torproxy/runtime/geoip.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/torproxy/runtime/geoip.py b/src/torproxy/runtime/geoip.py index 7b47176..e4e6c26 100644 --- a/src/torproxy/runtime/geoip.py +++ b/src/torproxy/runtime/geoip.py @@ -87,6 +87,8 @@ def _write_status( if previous_success is None: previous_success = previous.get("last_success_at") except (OSError, ValueError): + # No previous status file (first run) or unreadable/corrupt: + # start fresh, keeping only the just-recorded success time. pass payload = { "source": source,