Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .envrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use devenv
236 changes: 236 additions & 0 deletions .github/workflows/daily-image-verification.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
name: "Daily Live Image Verification"

on:
schedule:
# Daily at 04:00 UTC (before European business hours start)
- cron: "0 4 * * *"
workflow_dispatch:
inputs:
image_tag:
description: "Docker Image Tag to test (default: latest)"
required: true
default: "latest"

jobs:
verify-live-image:
name: "Live Tor Canary Verification"
runs-on: ubuntu-latest
permissions:
contents: read
issues: write

steps:
- name: Check out repository
uses: actions/checkout@v4

# -------------------------------------------------------------
# 1. Determine Host IP (for anti-leak assertion)
# -------------------------------------------------------------
- name: Determine Host IP
id: host-info
run: |
RUNNER_IP=$(curl -s --connect-timeout 10 https://api.ipify.org || curl -s --connect-timeout 10 https://icanhazip.com)
echo "Host Runner IP: $RUNNER_IP"
echo "runner_ip=$RUNNER_IP" >> "$GITHUB_OUTPUT"

# -------------------------------------------------------------
# 2. Pull and start current Docker image
# -------------------------------------------------------------
- name: Pull and Run torproxy Container
id: run-container
run: |
IMAGE_TAG="${{ github.event.inputs.image_tag || 'latest' }}"
IMAGE_NAME="hapheus/torproxy:${IMAGE_TAG}"
echo "Pulling Docker Image: $IMAGE_NAME..."
docker pull "$IMAGE_NAME"

echo "Starting Container..."
CONTAINER_ID=$(docker run -d \
--name torproxy-canary \
-p 8118:8118 \
-p 9050:9050 \
-p 8080:8080 \
"$IMAGE_NAME")
echo "container_id=$CONTAINER_ID" >> "$GITHUB_OUTPUT"

# -------------------------------------------------------------
# 3. Live check: wait for 100% Tor bootstrap (SLA: max 180s)
# -------------------------------------------------------------
- name: Wait for Tor 100% Bootstrap
timeout-minutes: 4
run: |
echo "Waiting for Tor Bootstrap via REST API (http://localhost:8080/status)..."
BOOTSTRAPPED=false
for i in {1..36}; do
STATUS_JSON=$(curl -s --connect-timeout 5 http://localhost:8080/status || echo "{}")
STATUS=$(echo "$STATUS_JSON" | jq -r '.status // "unknown"')
DETAIL=$(echo "$STATUS_JSON" | jq -r '.detail // ""')

echo "Attempt $i/36: Status='$STATUS', Detail='$DETAIL'"

if [ "$STATUS" = "connected" ]; then
echo "✅ Tor successfully bootstrapped and connected!"
BOOTSTRAPPED=true
break
fi
sleep 5
done

if [ "$BOOTSTRAPPED" != "true" ]; then
echo "❌ ERROR: Tor failed to reach 100% bootstrap within 180 seconds."
echo "=== Container Logs ==="
docker logs torproxy-canary
exit 1
fi

# -------------------------------------------------------------
# 4. HTTP proxy (Privoxy :8118) egress & anonymity check
# -------------------------------------------------------------
- name: Verify HTTP Proxy (Port 8118) Egress & Anonymity
run: |
RUNNER_IP="${{ steps.host-info.outputs.runner_ip }}"
echo "Testing Privoxy HTTP Proxy via http://127.0.0.1:8118..."

RESP=$(curl -s --connect-timeout 20 --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip)
echo "Privoxy Response: $RESP"

IS_TOR=$(echo "$RESP" | jq -r '.IsTor')
EXIT_IP=$(echo "$RESP" | jq -r '.IP')

if [ "$IS_TOR" != "true" ]; then
echo "❌ ERROR: Request via Privoxy is NOT recognized as Tor traffic (IsTor=$IS_TOR)."
exit 1
fi

if [ "$EXIT_IP" = "$RUNNER_IP" ]; then
echo "❌ CRITICAL: IP Leak detected! Exit IP matches Runner Host IP ($EXIT_IP)."
exit 1
fi

echo "✅ HTTP Proxy OK: Exit-IP is $EXIT_IP (IsTor=true, IP != Host)"
echo "initial_http_ip=$EXIT_IP" >> "$GITHUB_ENV"

# -------------------------------------------------------------
# 5. SOCKS5 proxy (Tor :9050) egress & remote DNS check
# -------------------------------------------------------------
- name: Verify SOCKS5 Proxy (Port 9050) Egress & DNS Resolution
run: |
RUNNER_IP="${{ steps.host-info.outputs.runner_ip }}"
echo "Testing Tor SOCKS5 Proxy via socks5h://127.0.0.1:9050 (Remote DNS)..."

RESP=$(curl -s --connect-timeout 20 --socks5-hostname 127.0.0.1:9050 https://check.torproject.org/api/ip)
echo "SOCKS5 Response: $RESP"

IS_TOR=$(echo "$RESP" | jq -r '.IsTor')
EXIT_IP=$(echo "$RESP" | jq -r '.IP')

if [ "$IS_TOR" != "true" ]; then
echo "❌ ERROR: Request via SOCKS5 is NOT recognized as Tor traffic (IsTor=$IS_TOR)."
exit 1
fi

if [ "$EXIT_IP" = "$RUNNER_IP" ]; then
echo "❌ CRITICAL: SOCKS5 IP Leak detected! ($EXIT_IP)."
exit 1
fi

echo "✅ SOCKS5 Proxy OK: Exit-IP is $EXIT_IP (IsTor=true)"

# -------------------------------------------------------------
# 6. Circuit rotation (/reconnect -> SIGNAL NEWNYM)
# -------------------------------------------------------------
- name: Verify Circuit Rotation (/reconnect -> SIGNAL NEWNYM)
run: |
OLD_IP="${{ env.initial_http_ip }}"
echo "Current Exit IP before rotation: $OLD_IP"

echo "Triggering IP reconnect via REST API..."
RECONNECT_RESP=$(curl -s -X POST http://localhost:8080/reconnect)
echo "Reconnect Response: $RECONNECT_RESP"

# Tor rate-limits SIGNAL NEWNYM if sent within 10s of a previous signal
echo "Waiting 15 seconds for new Tor circuit establishment..."
sleep 15

NEW_RESP=$(curl -s --connect-timeout 20 --proxy http://127.0.0.1:8118 https://check.torproject.org/api/ip)
echo "New Check Response: $NEW_RESP"

IS_TOR_NEW=$(echo "$NEW_RESP" | jq -r '.IsTor')
NEW_IP=$(echo "$NEW_RESP" | jq -r '.IP')

if [ "$IS_TOR_NEW" != "true" ]; then
echo "❌ ERROR: Rotated circuit is NOT recognized as Tor traffic."
exit 1
fi

echo "Initial IP: $OLD_IP"
echo "New IP: $NEW_IP"

if [ "$NEW_IP" = "$OLD_IP" ]; then
echo "⚠️ Warning: Exit IP unchanged after rotation ($NEW_IP). Note: Tor may select same exit node if circuit pool is small, but circuit was refreshed."
else
echo "✅ Circuit successfully rotated to new Exit-IP: $NEW_IP"
fi

# -------------------------------------------------------------
# 7. Generate step summary
# -------------------------------------------------------------
- name: Generate Step Summary
if: always()
run: |
echo "### 🛡️ Daily Torproxy Canary Verification Summary" >> "$GITHUB_STEP_SUMMARY"
echo "- **Image:** \`hapheus/torproxy:${{ github.event.inputs.image_tag || 'latest' }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- **Runner Host IP:** \`${{ steps.host-info.outputs.runner_ip }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- **Initial Tor Exit IP:** \`${{ env.initial_http_ip }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- **Result:** ${{ job.status == 'success' && '✅ PASSED' || '❌ FAILED' }}" >> "$GITHUB_STEP_SUMMARY"

# -------------------------------------------------------------
# 8. Stop and clean up container
# -------------------------------------------------------------
- name: Cleanup Container
if: always()
run: |
echo "Stopping and cleaning up container..."
docker stop torproxy-canary || true
docker rm -f torproxy-canary || true

# -------------------------------------------------------------
# 9. Alert on failure (GitHub issue automation)
# -------------------------------------------------------------
- name: Alert on Failure (Create/Update Issue)
if: failure()
uses: actions/github-script@v7
with:
script: |
const issueTitle = "🚨 Daily Live Image Verification Failed";
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `### 🚨 Daily Canary Failure Detected\n\n` +
`The scheduled daily verification of \`hapheus/torproxy:latest\` failed.\n\n` +
`- **Run:** [View Action Run](${runUrl})\n` +
`- **Date:** ${new Date().toISOString()}\n` +
`- **Cause:** Bootstrap timeout, proxy failure, or Tor network / Directory Authority outage.\n\n` +
`Please check the workflow logs for container output and exit status.`;

const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: ['canary-alert']
});

if (issues.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issues[0].number,
body: `**Repeated Failure:** Canary run failed again on ${new Date().toISOString()}.\n[View Run](${runUrl})`
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: issueTitle,
body: body,
labels: ['canary-alert', 'bug', 'priority/p1-urgent']
});
}
151 changes: 151 additions & 0 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
name: "PR Quality & Smoke Gate"

on:
pull_request:
branches:
- main
push:
branches:
- main

permissions:
contents: read

jobs:
lint-and-validate:
name: "Lint & Static Analysis"
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install Linting Tools
run: |
python -m pip install --upgrade pip
pip install ruff

- name: Run Ruff Linter
run: |
ruff check .

- name: Validate Shell Script Syntax
run: |
bash -n docker/start.sh
bash -n docker/healthcheck.sh

- name: Run ShellCheck
run: |
shellcheck docker/start.sh
shellcheck docker/healthcheck.sh

unit-tests:
name: "Hermetic Unit Tests"
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install Test Dependencies
run: |
python -m pip install --upgrade pip
pip install pytest

- name: Run Pytest (Hermetic Unit Tests)
run: |
pytest tests/unit/ -v

docker-smoke:
name: "Docker Build & Container Smoke Test"
runs-on: ubuntu-latest
needs: [lint-and-validate, unit-tests]
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Log in to Docker Hub (Rate Limit Exemption)
uses: docker/login-action@v3
if: env.DOCKERHUB_TOKEN != ''
with:
registry: docker.io
username: hapheus
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build Docker Image
run: |
BUILD_SUCCESS=false
for i in {1..5}; do
echo "Attempt $i: Building Docker image..."
if docker build -t torproxy:smoke-test ./docker; then
BUILD_SUCCESS=true
break
fi
echo "Build failed (possibly Docker Hub rate limit 429). Retrying in 10s..."
sleep 10
done
if [ "$BUILD_SUCCESS" != "true" ]; then
echo "❌ Failed to build Docker image after 5 attempts."
exit 1
fi

- name: Run Container for Smoke Test
id: run-container
run: |
CONTAINER_ID=$(docker run -d \
--name torproxy-smoke \
-p 8118:8118 \
-p 9050:9050 \
-p 8080:8080 \
torproxy:smoke-test)
echo "container_id=$CONTAINER_ID" >> "$GITHUB_OUTPUT"
# Brief sleep to allow processes to start
sleep 3

- name: Verify Non-Root User Execution
run: |
CONTAINER_UID=$(docker exec torproxy-smoke id -u)
echo "Container User UID: $CONTAINER_UID"
if [ "$CONTAINER_UID" -eq 0 ]; then
echo "❌ CRITICAL SECURITY ERROR: Container is running as root (UID 0)!"
exit 1
fi
echo "✅ Container is running as non-root user (UID: $CONTAINER_UID)."

- name: Verify Daemon Processes
run: |
PROCS=$(docker top torproxy-smoke)
echo "$PROCS"
echo "$PROCS" | grep -q "tor" || (echo "❌ tor daemon is not running" && exit 1)
echo "$PROCS" | grep -q "privoxy" || (echo "❌ privoxy is not running" && exit 1)
echo "$PROCS" | grep -q "python3" || (echo "❌ API process is not running" && exit 1)
echo "✅ All required daemons (tor, privoxy, api.py) are running."

- name: Verify Graceful Shutdown
run: |
echo "Stopping container with 5s timeout to test SIGTERM handling..."
STOP_START=$(date +%s)
docker stop -t 5 torproxy-smoke
STOP_END=$(date +%s)
STOP_DURATION=$((STOP_END - STOP_START))
echo "Container stopped in $STOP_DURATION seconds."
if [ "$STOP_DURATION" -ge 10 ]; then
echo "❌ Container failed graceful shutdown within timeout (was SIGKILLed)."
exit 1
fi
echo "✅ Graceful shutdown successful."

- name: Cleanup Smoke Container
if: always()
run: |
docker rm -f torproxy-smoke || true
Loading
Loading