Files
Charon/scripts/rate_limit_integration.sh
T
GitHub Actions 61418fa9dd fix(security): persist RateLimitMode in Upsert and harden integration test payload
- The security config Upsert update path copied all rate limit fields
  from the incoming request onto the existing database record except
  RateLimitMode, so the seeded default value of "disabled" always
  survived a POST regardless of what the caller sent
- This silently prevented the Caddy rate_limit handler from being
  injected on any container with a pre-existing config record (i.e.,
  every real deployment and every CI run after migration)
- Added the missing field assignment so RateLimitMode is correctly
  persisted on update alongside all other rate limit settings
- Integration test payload now also sends rate_limit_enable alongside
  rate_limit_mode so the handler sync logic fires via its explicit
  first branch, providing belt-and-suspenders correctness independent
  of which path the caller uses to express intent
2026-03-17 17:06:02 +00:00

447 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Brief: Integration test for Rate Limiting using Docker Compose and built image
# Steps:
# 1. Build the local image if not present: docker build -t charon:local .
# 2. Start Charon container with rate limiting enabled
# 3. Create a test proxy host via API
# 4. Configure rate limiting with short windows (3 requests per 10 seconds)
# 5. Send rapid requests and verify:
# - First N requests return HTTP 200
# - Request N+1 returns HTTP 429
# - Retry-After header is present on blocked response
# 6. Wait for window to reset, verify requests allowed again
# 7. Clean up test resources
# Ensure we operate from repo root
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# ============================================================================
# Configuration
# ============================================================================
RATE_LIMIT_REQUESTS=3
RATE_LIMIT_WINDOW_SEC=10
RATE_LIMIT_BURST=1
CONTAINER_NAME="charon-ratelimit-test"
BACKEND_CONTAINER="ratelimit-backend"
TEST_DOMAIN="ratelimit.local"
# ============================================================================
# Helper Functions
# ============================================================================
# Verifies rate limit handler is present in Caddy config
verify_rate_limit_config() {
local retries=10
local wait=5
echo "Verifying rate limit config in Caddy..."
for i in $(seq 1 $retries); do
# Fetch Caddy config via admin API
local caddy_config
caddy_config=$(curl -s http://localhost:2119/config/ 2>/dev/null || echo "")
if [ -z "$caddy_config" ]; then
echo " Attempt $i/$retries: Caddy admin API not responding, retrying..."
sleep $wait
continue
fi
# Check for rate_limit handler
if echo "$caddy_config" | grep -q '"handler":"rate_limit"'; then
echo " ✓ rate_limit handler found in Caddy config"
return 0
else
echo " Attempt $i/$retries: rate_limit handler not found, waiting..."
fi
sleep $wait
done
echo " ✗ rate_limit handler verification failed after $retries attempts"
return 1
}
# Dumps debug information on failure
on_failure() {
local exit_code=$?
echo ""
echo "=============================================="
echo "=== FAILURE DEBUG INFO (exit code: $exit_code) ==="
echo "=============================================="
echo ""
echo "=== Charon API Logs (last 150 lines) ==="
docker logs ${CONTAINER_NAME} 2>&1 | tail -150 || echo "Could not retrieve container logs"
echo ""
echo "=== Caddy Admin API Config ==="
curl -s http://localhost:2119/config/ 2>/dev/null | head -300 || echo "Could not retrieve Caddy config"
echo ""
echo "=== Security Config in API ==="
curl -s http://localhost:8280/api/v1/security/config 2>/dev/null || echo "Could not retrieve security config"
echo ""
echo "=== Proxy Hosts ==="
curl -s http://localhost:8280/api/v1/proxy-hosts 2>/dev/null | head -50 || echo "Could not retrieve proxy hosts"
echo ""
echo "=============================================="
echo "=== END DEBUG INFO ==="
echo "=============================================="
}
# Cleanup function
cleanup() {
echo "Cleaning up test resources..."
docker rm -f ${BACKEND_CONTAINER} 2>/dev/null || true
docker rm -f ${CONTAINER_NAME} 2>/dev/null || true
rm -f "${TMP_COOKIE:-}" 2>/dev/null || true
echo "Cleanup complete"
}
# Set up trap to dump debug info on any error
trap on_failure ERR
echo "=============================================="
echo "=== Rate Limit Integration Test Starting ==="
echo "=============================================="
echo ""
# Check dependencies
if ! command -v docker >/dev/null 2>&1; then
echo "docker is not available; aborting"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "curl is not available; aborting"
exit 1
fi
# ============================================================================
# Step 1: Build image if needed
# ============================================================================
if ! docker image inspect charon:local >/dev/null 2>&1; then
echo "Building charon:local image..."
docker build -t charon:local .
else
echo "Using existing charon:local image"
fi
# ============================================================================
# Step 2: Start Charon container
# ============================================================================
echo "Stopping any existing test containers..."
docker rm -f ${CONTAINER_NAME} 2>/dev/null || true
docker rm -f ${BACKEND_CONTAINER} 2>/dev/null || true
# Ensure network exists
if ! docker network inspect containers_default >/dev/null 2>&1; then
echo "Creating containers_default network..."
docker network create containers_default
fi
echo "Starting Charon container..."
docker run -d --name ${CONTAINER_NAME} \
--cap-add=SYS_PTRACE --security-opt seccomp=unconfined \
--network containers_default \
-p 8180:80 -p 8143:443 -p 8280:8080 -p 2119:2019 \
-e CHARON_ENV=development \
-e CHARON_DEBUG=1 \
-e CHARON_HTTP_PORT=8080 \
-e CHARON_DB_PATH=/app/data/charon.db \
-e CHARON_FRONTEND_DIR=/app/frontend/dist \
-e CHARON_CADDY_ADMIN_API=http://localhost:2019 \
-e CHARON_CADDY_CONFIG_DIR=/app/data/caddy \
-e CHARON_CADDY_BINARY=caddy \
-v charon_ratelimit_data:/app/data \
-v caddy_ratelimit_data:/data \
-v caddy_ratelimit_config:/config \
charon:local
echo "Waiting for Charon API to be ready..."
for i in {1..30}; do
if curl -s -f http://localhost:8280/api/v1/health >/dev/null 2>&1; then
echo "✓ Charon API is ready"
break
fi
if [ $i -eq 30 ]; then
echo "✗ Charon API failed to start"
exit 1
fi
echo -n '.'
sleep 1
done
# ============================================================================
# Step 3: Create backend container
# ============================================================================
echo ""
echo "Creating backend container for proxy host..."
docker pull mccutchen/go-httpbin 2>/dev/null || true
docker run -d --name ${BACKEND_CONTAINER} --network containers_default -e PORT=80 mccutchen/go-httpbin
echo "Waiting for httpbin backend to be ready..."
for i in {1..45}; do
if docker exec ${CONTAINER_NAME} sh -c "wget -qO /dev/null http://${BACKEND_CONTAINER}/get" >/dev/null 2>&1; then
echo "✓ httpbin backend is ready"
break
fi
if [ $i -eq 45 ]; then
echo "✗ httpbin backend failed to start"
exit 1
fi
echo -n '.'
sleep 1
done
# ============================================================================
# Step 4: Register user and authenticate
# ============================================================================
echo ""
echo "Registering admin user and logging in..."
TMP_COOKIE=$(mktemp)
curl -s -X POST -H "Content-Type: application/json" \
-d '{"email":"ratelimit@example.local","password":"password123","name":"Rate Limit Tester"}' \
http://localhost:8280/api/v1/auth/register >/dev/null 2>&1 || true
LOGIN_STATUS=$(curl -s -w "\n%{http_code}" -X POST -H "Content-Type: application/json" \
-d '{"email":"ratelimit@example.local","password":"password123"}' \
-c ${TMP_COOKIE} \
http://localhost:8280/api/v1/auth/login | tail -n1)
if [ "$LOGIN_STATUS" != "200" ]; then
echo "✗ Login failed (HTTP $LOGIN_STATUS) — aborting"
exit 1
fi
echo "✓ Authentication complete (HTTP $LOGIN_STATUS)"
# ============================================================================
# Step 5: Create proxy host
# ============================================================================
echo ""
echo "Creating proxy host '${TEST_DOMAIN}' pointing to backend..."
PROXY_HOST_PAYLOAD=$(cat <<EOF
{
"name": "ratelimit-backend",
"domain_names": "${TEST_DOMAIN}",
"forward_scheme": "http",
"forward_host": "${BACKEND_CONTAINER}",
"forward_port": 80,
"enabled": true
}
EOF
)
CREATE_RESP=$(curl -s -w "\n%{http_code}" -X POST -H "Content-Type: application/json" \
-d "${PROXY_HOST_PAYLOAD}" \
-b ${TMP_COOKIE} \
http://localhost:8280/api/v1/proxy-hosts)
CREATE_STATUS=$(echo "$CREATE_RESP" | tail -n1)
if [ "$CREATE_STATUS" = "201" ]; then
echo "✓ Proxy host created successfully"
elif [ "$CREATE_STATUS" = "401" ] || [ "$CREATE_STATUS" = "403" ]; then
echo "✗ Proxy host creation failed — authentication/authorization error (HTTP $CREATE_STATUS)"
exit 1
else
echo " Proxy host may already exist or was created (status: $CREATE_STATUS) — continuing"
fi
# ============================================================================
# Step 6: Configure rate limiting
# ============================================================================
echo ""
echo "Configuring rate limiting: ${RATE_LIMIT_REQUESTS} requests per ${RATE_LIMIT_WINDOW_SEC} seconds..."
SEC_CFG_PAYLOAD=$(cat <<EOF
{
"name": "default",
"enabled": true,
"rate_limit_mode": "enabled",
"rate_limit_enable": true,
"rate_limit_requests": ${RATE_LIMIT_REQUESTS},
"rate_limit_window_sec": ${RATE_LIMIT_WINDOW_SEC},
"rate_limit_burst": ${RATE_LIMIT_BURST},
"admin_whitelist": "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
}
EOF
)
echo "Waiting for Caddy admin API to be ready..."
for i in {1..20}; do
if curl -s -f http://localhost:2119/config/ >/dev/null 2>&1; then
echo "✓ Caddy admin API is ready"
break
fi
if [ $i -eq 20 ]; then
echo "✗ Caddy admin API failed to become ready"
exit 1
fi
echo -n '.'
sleep 1
done
SEC_CONFIG_RESP=$(curl -s -w "\n%{http_code}" -X POST -H "Content-Type: application/json" \
-d "${SEC_CFG_PAYLOAD}" \
-b ${TMP_COOKIE} \
http://localhost:8280/api/v1/security/config)
SEC_CONFIG_STATUS=$(echo "$SEC_CONFIG_RESP" | tail -n1)
SEC_CONFIG_BODY=$(echo "$SEC_CONFIG_RESP" | head -n-1)
if [ "$SEC_CONFIG_STATUS" != "200" ]; then
echo "✗ Security config update failed (HTTP $SEC_CONFIG_STATUS)"
echo " Response body: $SEC_CONFIG_BODY"
echo " Verify the auth cookie is valid and the user has the admin role."
exit 1
fi
echo "✓ Rate limiting configured (HTTP $SEC_CONFIG_STATUS)"
echo "Waiting for Caddy to apply configuration..."
sleep 8
# Verify rate limit handler is configured — this is a hard requirement
if ! verify_rate_limit_config; then
echo "✗ Rate limit handler verification failed — aborting test"
echo " The handler must be present in Caddy config before enforcement can be tested."
echo ""
echo "=== Caddy admin API full config ==="
curl -s http://localhost:2119/config/ 2>/dev/null | head -200 || echo "Admin API not responding"
echo ""
echo "=== Security config from API ==="
curl -s -b ${TMP_COOKIE} http://localhost:8280/api/v1/security/config 2>/dev/null || echo "API not responding"
exit 1
fi
# ============================================================================
# Step 7: Test rate limiting enforcement
# ============================================================================
echo ""
echo "=============================================="
echo "=== Testing Rate Limit Enforcement ==="
echo "=============================================="
echo ""
echo "Sending ${RATE_LIMIT_REQUESTS} rapid requests (should all return 200)..."
SUCCESS_COUNT=0
for i in $(seq 1 ${RATE_LIMIT_REQUESTS}); do
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: ${TEST_DOMAIN}" http://localhost:8180/get)
if [ "$RESPONSE" = "200" ]; then
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
echo " Request $i: HTTP $RESPONSE"
else
echo " Request $i: HTTP $RESPONSE (expected 200)"
fi
# Small delay to avoid overwhelming, but still within the window
sleep 0.1
done
if [ $SUCCESS_COUNT -ne ${RATE_LIMIT_REQUESTS} ]; then
echo ""
echo "✗ Not all allowed requests succeeded ($SUCCESS_COUNT/${RATE_LIMIT_REQUESTS})"
echo "Rate limit enforcement test FAILED"
cleanup
exit 1
fi
echo ""
echo "Sending request ${RATE_LIMIT_REQUESTS}+1 (should return 429 Too Many Requests)..."
# Capture headers too for Retry-After check
BLOCKED_RESPONSE=$(curl -s -D - -o /dev/null -H "Host: ${TEST_DOMAIN}" http://localhost:8180/get)
BLOCKED_STATUS=$(echo "$BLOCKED_RESPONSE" | head -1 | grep -o '[0-9]\{3\}' | head -1)
if [ "$BLOCKED_STATUS" = "429" ]; then
echo " ✓ Request blocked with HTTP 429 as expected"
# Check for Retry-After header
if echo "$BLOCKED_RESPONSE" | grep -qi "Retry-After"; then
RETRY_AFTER=$(echo "$BLOCKED_RESPONSE" | grep -i "Retry-After" | head -1)
echo " ✓ Retry-After header present: $RETRY_AFTER"
else
echo " ⚠ Retry-After header not found (may be plugin-dependent)"
fi
else
echo " ✗ Expected HTTP 429, got HTTP $BLOCKED_STATUS"
echo ""
echo "=== DEBUG: SecurityConfig from API ==="
curl -s -b ${TMP_COOKIE} http://localhost:8280/api/v1/security/config | jq .
echo ""
echo "=== DEBUG: SecurityStatus from API ==="
curl -s -b ${TMP_COOKIE} http://localhost:8280/api/v1/security/status | jq .
echo ""
echo "=== DEBUG: Caddy config (first proxy route handlers) ==="
curl -s http://localhost:2119/config/ | jq '.apps.http.servers.charon_server.routes[0].handle // []'
echo ""
echo "=== DEBUG: Container logs (last 100 lines) ==="
docker logs ${CONTAINER_NAME} 2>&1 | tail -100
echo ""
echo "Rate limit enforcement test FAILED"
echo "Container left running for manual inspection"
echo "Run: docker logs ${CONTAINER_NAME}"
echo "Run: docker rm -f ${CONTAINER_NAME} ${BACKEND_CONTAINER}"
exit 1
fi
# ============================================================================
# Step 8: Test window reset
# ============================================================================
echo ""
echo "=============================================="
echo "=== Testing Window Reset ==="
echo "=============================================="
echo ""
echo "Waiting for rate limit window to reset (${RATE_LIMIT_WINDOW_SEC} seconds + buffer)..."
sleep $((RATE_LIMIT_WINDOW_SEC + 2))
echo "Sending request after window reset (should return 200)..."
RESET_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: ${TEST_DOMAIN}" http://localhost:8180/get)
if [ "$RESET_RESPONSE" = "200" ]; then
echo " ✓ Request allowed after window reset (HTTP 200)"
else
echo " ✗ Expected HTTP 200 after reset, got HTTP $RESET_RESPONSE"
echo ""
echo "Rate limit window reset test FAILED"
cleanup
exit 1
fi
# ============================================================================
# Step 9: Cleanup and report
# ============================================================================
echo ""
echo "=============================================="
echo "=== Rate Limit Integration Test Results ==="
echo "=============================================="
echo ""
echo "✓ Rate limit enforcement succeeded"
echo " - ${RATE_LIMIT_REQUESTS} requests allowed within window"
echo " - Request ${RATE_LIMIT_REQUESTS}+1 blocked with HTTP 429"
echo " - Requests allowed again after window reset"
echo ""
# Remove test proxy host from database
echo "Removing test proxy host from database..."
INTEGRATION_UUID=$(curl -s -b ${TMP_COOKIE} http://localhost:8280/api/v1/proxy-hosts | \
grep -o '"uuid":"[^"]*"[^}]*"domain_names":"'${TEST_DOMAIN}'"' | head -n1 | \
grep -o '"uuid":"[^"]*"' | sed 's/"uuid":"\([^"]*\)"/\1/')
if [ -n "$INTEGRATION_UUID" ]; then
curl -s -X DELETE -b ${TMP_COOKIE} \
"http://localhost:8280/api/v1/proxy-hosts/${INTEGRATION_UUID}?delete_uptime=true" >/dev/null
echo "✓ Deleted test proxy host ${INTEGRATION_UUID}"
fi
cleanup
echo ""
echo "=============================================="
echo "=== ALL RATE LIMIT TESTS PASSED ==="
echo "=============================================="
echo ""