fix: resolve 3 security audit issues

#3: Stored XSS in stats dashboard - escape p[path] with html.escape()
#4: Caddy timeout race - increase read/write_timeout 30s -> 60s
#5: Missing CSP header - add Content-Security-Policy to Caddyfile
This commit is contained in:
akiba
2026-06-30 14:03:48 +00:00
parent 085cc7a140
commit 122c408fff
32 changed files with 9066 additions and 859 deletions

590
app.py
View File

@@ -1,13 +1,17 @@
"""
AO3 反代后端 v4Cookie 感知 + CF 挑战用户浏览器求解
AO3 反代后端 v5粘性会话 + Service Worker + P2C 代理池 + 并发限流
v4 vs v3:
- Challenge token 映射:用户浏览器求解 CF 挑战时,保证同一 proxy 亲和性
- 挑战页面透传:所有代理都遇到 CF 挑战时,把挑战页发给用户浏览器求解
- Cookie 流:成功响应自动保存 proxy cookie后续请求自动携带
- 统计面板展示 cookie-aware 代理数
v5 vs v4:
- 粘性会话: ao3_sessid_proxy cookie (对标 go3)
- Service Worker: /sw.js, /sw-YYYYMMDD.js for client-side caching
- HTML 注入: 自动注入 SW 注册脚本 + 镜像域名
- P2C 代理选择: Power of Two Choices (对标 go3)
- 并发限流: asyncio.Semaphore(400) (Webshare 上限)
- 全量 5000 代理: 不再按端口过滤
- 状态机: healthy/unstable/blocked/probing
"""
import hashlib
import html
import json
import logging
import os
@@ -23,9 +27,9 @@ import uvicorn
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from proxy_pool import get_proxy_pool
from ao3_fetcher import fetch_url, is_cf_challenge
from url_rewriter import rewrite_body, rewrite_response_headers, needs_rewrite, MIRROR_DOMAIN
from proxy_pool import get_proxy_pool, MAX_CONCURRENT_REQUESTS, OUTAGE_THRESHOLD
from ao3_fetcher import fetch_url, is_cf_challenge, FAST_REQUEST_TIMEOUT, NORMAL_REQUEST_TIMEOUT
from url_rewriter import rewrite_body, rewrite_response_headers, rewrite_response_headers_raw, needs_rewrite, MIRROR_DOMAIN
from cache import get_cache, get_ttl_for_path
from stats import get_stats_collector
@@ -39,13 +43,15 @@ logger = logging.getLogger("ao3-backend")
AO3_BASE = "https://archiveofourown.org"
MIRROR_HOST = MIRROR_DOMAIN
# Service Worker version date (change on SW updates)
SERVICE_WORKER_DATE = "20260623"
LOCAL_PATHS = {"/stats", "/health", "/metrics", "/favicon.ico", "/robots.txt"}
# ─── Challenge Token Map (v4) ──────────────────────────────────────────────
# Maps challenge_token → (method, ao3_url, headers, body, cookies, proxy_host, expires)
# ─── Challenge Token Map (v4 retained) ──────────────────────────────────────
_challenge_map: dict[str, tuple] = {}
_challenge_lock = threading.Lock()
CHALLENGE_TOKEN_TTL = 120 # 2 minutes for the user's browser to solve the challenge
CHALLENGE_TOKEN_TTL = 120
def _make_challenge_token() -> str:
@@ -55,7 +61,6 @@ def _make_challenge_token() -> str:
def _store_challenge(token: str, method: str, ao3_url: str, headers: dict,
body: bytes, cookies: dict, proxy_host: str):
with _challenge_lock:
# Clean expired tokens
now = time.time()
expired = [k for k, v in _challenge_map.items() if v[6] < now]
for k in expired:
@@ -71,16 +76,38 @@ def _get_challenge(token: str) -> tuple | None:
del _challenge_map[token]
return entry
if entry:
del _challenge_map[token] # expired
del _challenge_map[token]
return None
# ─── Sticky Session ─────────────────────────────────────────────────────────
STICKY_COOKIE_NAME = "ao3_sessid_proxy"
def get_sticky_proxy_idx(request: Request) -> tuple[int, bool]:
"""Get sticky proxy index from cookie. Returns (idx, had_sticky)."""
c = request.cookies.get(STICKY_COOKIE_NAME)
if c is not None:
try:
idx = int(c)
pool = get_proxy_pool()
if 0 <= idx < len(pool.proxies):
return idx, True
except (ValueError, TypeError):
pass
# No sticky cookie — random
import random
pool = get_proxy_pool()
return random.randint(0, max(len(pool.proxies) - 1, 0)), False
# ─── App ───────────────────────────────────────────────────────────────────
app = FastAPI(
title="AO3 Mirror",
description="AO3 reverse proxy mirror for Chinese users",
version="4.0.0",
version="5.0.0",
docs_url=None,
redoc_url=None,
)
@@ -106,31 +133,51 @@ def build_ao3_url(path: str, query: str = "") -> str:
# ─── Challenge page URL rewriting ──────────────────────────────────────────
def _rewrite_challenge_page(body: bytes, proxy_host: str, token: str) -> bytes:
"""Rewrite CF challenge page so all URLs go through mirror, and tag with challenge token."""
import re
# Basic AO3 domain rewrite
"""Rewrite CF challenge page — inject retry UI + auto cookie."""
# Rewrite domain
body = body.replace(b"archiveofourown.org", MIRROR_DOMAIN.encode())
# Rewrite CF challenge paths — our CF CDN would intercept /cdn-cgi/,
# so we remap to /__cf/ which our proxy handler forwards to AO3
body = body.replace(b"/cdn-cgi/", b"/__cf/")
body = body.replace(b"cdn-cgi/challenge", b"__cf/challenge")
# Rewrite /cdn-cgi/ challenge endpoints to go through mirror
# These are CF's internal challenge platform URLs
body = body.replace(
b"/cdn-cgi/challenge-platform",
f"/cdn-cgi/challenge-platform".encode()
# Inject cookie + retry overlay
retry_overlay = (
f'<script>document.cookie="_cf_token={token};path=/;max-age={CHALLENGE_TOKEN_TTL}";</script>'.encode()
+ b'<style>#cf-retry-overlay{position:fixed;top:0;left:0;right:0;background:#990000;color:#fff;text-align:center;padding:12px;z-index:99999;font-family:sans-serif}'
+ b'#cf-retry-overlay a{color:#fff;font-weight:bold;text-decoration:underline}</style>'
+ b'<div id="cf-retry-overlay">'
+ 'AO3 Mirror - CF challenge solving in background. '.encode()
+ '<a href="javascript:location.reload()">Click to retry</a> after a few seconds.'.encode()
+ b'</div>'
)
# Inject a marker meta tag so we can detect challenge pages coming back
marker = f'<meta name="cf-challenge-proxy" content="{proxy_host}">'.encode()
marker_cookie = (
f'<script>document.cookie="_cf_token={token};path=/;max-age={CHALLENGE_TOKEN_TTL}";</script>'
).encode()
body = body.replace(b"<head>", b"<head>" + marker + marker_cookie, 1)
if b"<head>" not in body:
body = b"<head>" + marker + marker_cookie + b"</head>" + body
body = body.replace(b"<body", retry_overlay + b"<body", 1)
if b"<body" not in body:
body = retry_overlay + body
return body
# ─── HTML injection (Service Worker) ───────────────────────────────────────
def inject_service_worker(html_body: bytes) -> bytes:
"""Inject SW registration script into HTML, before </head>."""
import re
sw_script = (
b'<script>'
b'if("serviceWorker"in navigator){'
b'navigator.serviceWorker.register("/sw.js",{scope:"/"}).catch(function(){})'
b'}'
b'</script>'
)
# Match </head> with optional leading whitespace
match = re.search(rb'\s*</head>', html_body)
if match:
pos = match.start()
return html_body[:pos] + sw_script + html_body[pos:]
# Fallback: insert at beginning if no head tag
return sw_script + html_body
# ─── Response header helpers ───────────────────────────────────────────────
def filter_response_headers(headers: dict) -> dict:
@@ -149,7 +196,7 @@ def filter_response_headers(headers: dict) -> dict:
if key.lower().startswith("cf-"):
continue
result[key] = value
result["X-Proxy"] = "AO3-Mirror/4.0"
result["X-Proxy"] = "AO3-Mirror/5.0"
result["X-Cache"] = "MISS"
result["Cache-Control"] = "public, max-age=60, s-maxage=60"
return result
@@ -169,15 +216,12 @@ def add_cors(response: Response):
async def health():
pool = get_proxy_pool()
stats = pool.get_stats()
resp = JSONResponse({
return JSONResponse({
"status": "ok",
"timestamp": time.time(),
"version": "4.0.0",
"version": "5.0.0",
"proxy_pool": stats,
})
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
resp.headers["Pragma"] = "no-cache"
return resp
@app.get("/robots.txt")
@@ -187,6 +231,43 @@ async def robots():
)
# ─── Service Worker Routes ─────────────────────────────────────────────────
@app.get("/sw.js")
async def service_worker_redirect():
"""Redirect to versioned SW file."""
return RedirectResponse(url=f"/sw-{SERVICE_WORKER_DATE}.js", status_code=302)
@app.get("/sw-{date}.js")
async def service_worker(date: str):
"""Serve versioned Service Worker."""
sw_path = os.path.join(os.path.dirname(__file__), "static", "sw.js")
try:
with open(sw_path, "rb") as f:
sw_content = f.read()
except FileNotFoundError:
return Response(status_code=404)
return Response(
content=sw_content,
media_type="application/javascript",
headers={
"Cache-Control": "public, max-age=86400, immutable",
"Service-Worker-Allowed": "/",
},
)
@app.get("/mirror-domains.json")
async def mirror_domains():
"""JSON endpoint for SW cross-domain awareness."""
return JSONResponse({
"domains": [MIRROR_DOMAIN],
"primary": MIRROR_DOMAIN,
})
# ─── Stats Dashboard ───────────────────────────────────────────────────────
@app.get("/stats", response_class=HTMLResponse)
@@ -213,7 +294,7 @@ async def stats_page():
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AO3 Mirror - 统计面板 v4</title>
<title>AO3 Mirror - 统计面板 v5</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
@@ -241,12 +322,13 @@ async def stats_page():
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; }}
.badge-green {{ background: rgba(74, 222, 128, 0.15); color: #4ade80; }}
.badge-red {{ background: rgba(248, 113, 113, 0.15); color: #f87171; }}
.badge-yellow {{ background: rgba(251, 191, 36, 0.15); color: #fbbf24; }}
@media (max-width: 768px) {{ .chart-row {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<div class="container">
<h1>🛡 AO3 Mirror 统计面板 v4</h1>
<h1>🛡 AO3 Mirror 统计面板 v5</h1>
<div class="grid">
<div class="card">
@@ -275,14 +357,14 @@ async def stats_page():
<div class="sub">P99: {stats['p99_elapsed_ms']} ms</div>
</div>
<div class="card">
<div class="label">代理池</div>
<div class="value green">{proxy_stats['alive']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
<div class="sub">可用: {proxy_stats['available']} | 受损: {proxy_stats['dead']} | Banned: {proxy_stats['banned']}</div>
<div class="label">代理池 ({proxy_stats['total']})</div>
<div class="value green">{proxy_stats['healthy']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
<div class="sub">不稳定: {proxy_stats['unstable']} | 封禁: {proxy_stats['blocked']} | 探测: {proxy_stats['probing']}</div>
</div>
<div class="card">
<div class="label">Cookie 代理</div>
<div class="value orange">{proxy_stats['with_cookies']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['alive']}</span></div>
<div class="sub">已持有 cf_clearance</div>
<div class="value orange">{proxy_stats['with_cookies']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
<div class="sub">inflight: {proxy_stats['total_inflight']}</div>
</div>
<div class="card">
<div class="label">本地缓存</div>
@@ -306,7 +388,7 @@ async def stats_page():
<h2>热门路径</h2>
<table>
<tr><th>路径</th><th>请求数</th><th>成功</th><th>平均延迟</th></tr>
{''.join(f'<tr><td>{p["path"]}</td><td>{p["requests"]:,}</td><td><span class="badge {"badge-green" if p["requests"]==0 or p["successful"]/max(p["requests"],1)>0.8 else "badge-red"}">{round(p["successful"]/max(p["requests"],1)*100)}%</span></td><td>{p["avg_elapsed"]*1000:.0f}ms</td></tr>' for p in stats['top_paths'][:15])}
{''.join(f'<tr><td>{html.escape(p["path"])}</td><td>{p["requests"]:,}</td><td><span class="badge {"badge-green" if p["requests"]==0 or p["successful"]/max(p["requests"],1)>0.8 else "badge-red"}">{round(p["successful"]/max(p["requests"],1)*100)}%</span></td><td>{p["avg_elapsed"]*1000:.0f}ms</td></tr>' for p in stats['top_paths'][:15])}
</table>
</div>
</div>
@@ -394,11 +476,13 @@ async def metrics():
f'ao3_mirror_avg_elapsed_ms {stats["avg_elapsed_ms"]}',
"# HELP ao3_mirror_proxy_pool Proxy pool status",
"# TYPE ao3_mirror_proxy_pool gauge",
f'ao3_mirror_proxy_alive {proxy_stats["alive"]}',
f'ao3_mirror_proxy_dead {proxy_stats["dead"]}',
f'ao3_mirror_proxy_banned {proxy_stats["banned"]}',
f'ao3_mirror_proxy_available {proxy_stats["available"]}',
f'ao3_mirror_proxy_total {proxy_stats["total"]}',
f'ao3_mirror_proxy_healthy {proxy_stats["healthy"]}',
f'ao3_mirror_proxy_unstable {proxy_stats["unstable"]}',
f'ao3_mirror_proxy_blocked {proxy_stats["blocked"]}',
f'ao3_mirror_proxy_probing {proxy_stats["probing"]}',
f'ao3_mirror_proxy_with_cookies {proxy_stats["with_cookies"]}',
f'ao3_mirror_proxy_inflight {proxy_stats["total_inflight"]}',
"# HELP ao3_mirror_cache_size Current cache size",
"# TYPE ao3_mirror_cache_size gauge",
f'ao3_mirror_cache_size {cache_stats["size"]}',
@@ -406,11 +490,11 @@ async def metrics():
return PlainTextResponse("\n".join(lines))
# ─── Proxy Core (v4) ──────────────────────────────────────────────────────
# ─── Proxy handler (v5) ──────────────────────────────────────────────────
@app.api_route("/{path:path}", methods=["GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH"])
async def proxy_handler(request: Request, path: str):
"""Main proxy handler — with cookie-aware fetching and CF challenge solving."""
"""Main proxy handler — sticky sessions + P2C selection + semaphore-limited."""
start_time = time.time()
client_ip = get_client_ip(request)
@@ -418,88 +502,93 @@ async def proxy_handler(request: Request, path: str):
path = ""
full_path = f"/{path}" if path else "/"
if full_path in LOCAL_PATHS or full_path.startswith("/stats") or full_path.startswith("/health"):
if full_path in LOCAL_PATHS or full_path.startswith("/sw"):
return JSONResponse({"error": "Not found"}, status_code=404)
# Remap /__cf/ → /cdn-cgi/ (CF challenge paths bypass our CDN)
if full_path.startswith("/__cf/"):
path = "cdn-cgi" + path[4:]
full_path = "/" + path
if request.method == "OPTIONS":
resp = Response()
add_cors(resp)
return resp
# ── v4: Challenge token check ──────────────────────────────────────────
# If this request has a _cf_token cookie, it's part of a challenge resolution flow
pool = get_proxy_pool()
query_string = request.url.query
ao3_url = build_ao3_url(f"/{path}" if path else "/", query_string)
# ── v4: Challenge token check ────────────────────────────────────────
cf_token = request.cookies.get("_cf_token") or request.query_params.get("_cf_token")
preferred_proxy = None
if cf_token:
challenge_entry = _get_challenge(cf_token)
if challenge_entry:
orig_method, orig_url, orig_headers, orig_body, orig_cookies, proxy_host, _ = challenge_entry
preferred_proxy = proxy_host
logger.info(f"Challenge resolution: using proxy {proxy_host} for {orig_url[:60]}")
# Re-fetch the original request with the challenge proxy
logger.info(f"Challenge resolution: using proxy {proxy_host}")
challenge_proxy_obj = pool.get_by_addr(proxy_host)
result = await fetch_url(
url=orig_url, method=orig_method,
proxy=challenge_proxy_obj,
headers=dict(request.headers),
body=await request.body() if orig_method in ("POST", "PUT", "PATCH") else None,
cookies=orig_cookies,
is_api="/api/" in orig_url,
preferred_proxy=preferred_proxy,
)
if result["success"]:
# Challenge solved! Proxy now has cf_clearance. Return content.
elapsed = time.time() - start_time
rewritten_body = rewrite_body(result["body"],
result["headers"].get("Content-Type", ""))
result["headers"].get("Content-Type", result["headers"].get("content-type", "")))
content_type = result["headers"].get("Content-Type", result["headers"].get("content-type", ""))
if "text/html" in content_type:
rewritten_body = inject_service_worker(rewritten_body)
rewritten_headers = rewrite_response_headers(result["headers"])
final_headers = filter_response_headers(rewritten_headers)
final_headers.pop("Set-Cookie", None)
final_headers.pop("set-cookie", None)
final_headers["X-Cache"] = "MISS"
final_headers["X-CF-Status"] = "solved"
# Set cf_clearance as a cookie on the mirror domain too
# so subsequent requests benefit
resp = Response(content=rewritten_body, status_code=result["status"],
headers=final_headers)
# Forward any Set-Cookie from AO3 (includes cf_clearance)
for k, v in result["headers"].items():
if k.lower() == "set-cookie":
# Rewrite domain
v_rewritten = v.replace("domain=archiveofourown.org",
f"domain={MIRROR_DOMAIN}")
v_rewritten = v_rewritten.replace("domain=.archiveofourown.org",
f"domain=.{MIRROR_DOMAIN}")
resp.headers.add("Set-Cookie", v_rewritten)
# Clean up challenge token cookie
# Append ALL Set-Cookie from raw (preserves multi-value)
# Filter: strip AO3 CF cookies
SKIP_CF = {"__cf_bm", "_cfuvid"}
ao3_headers_raw = result.get("headers_raw", [])
if ao3_headers_raw:
rewritten_raw = rewrite_response_headers_raw(ao3_headers_raw)
for key, value in rewritten_raw:
if key.lower() == "set-cookie":
cookie_name = value.split("=")[0].split(";")[0].strip()
if cookie_name in SKIP_CF:
continue
resp.headers.append("Set-Cookie", value)
resp.delete_cookie("_cf_token", path="/")
collector = get_stats_collector()
collector.log_request(method=orig_method, path=full_path,
status=result["status"], elapsed=elapsed,
cached=False, proxy_host=preferred_proxy,
cached=False, proxy_host=proxy_host,
client_ip=client_ip)
add_cors(resp)
return resp
# ── Normal request flow ────────────────────────────────────────────────
query_string = request.url.query
ao3_url = build_ao3_url(f"/{path}" if path else "/", query_string)
# Check cache
# ── Static cache check (static assets only) ────────────────────
from cache import is_static_path
cache = get_cache()
cached = cache.get(ao3_url, dict(request.headers))
if cached:
body, resp_headers, status = cached
elapsed = time.time() - start_time
collector = get_stats_collector()
collector.log_request(method=request.method, path=full_path, status=status,
elapsed=elapsed, cached=True, client_ip=client_ip)
headers = filter_response_headers(resp_headers)
headers["X-Cache"] = "HIT"
return Response(content=body, status_code=status, headers=headers)
if request.method == "GET" and is_static_path(full_path):
cached = cache.get(ao3_url)
if cached:
body, resp_headers, status = cached
elapsed = time.time() - start_time
collector = get_stats_collector()
collector.log_request(method=request.method, path=full_path, status=status,
elapsed=elapsed, cached=True, client_ip=client_ip)
return Response(content=body, status_code=status, headers=resp_headers)
# Prepare request
# ── Prepare request ──────────────────────────────────────────────────
method = request.method
client_headers = dict(request.headers)
req_body = None
@@ -514,101 +603,167 @@ async def proxy_handler(request: Request, path: str):
k, v = pair.split("=", 1)
cookies[k.strip()] = v.strip()
# Forward to AO3
result = await fetch_url(
url=ao3_url, method=method,
headers=client_headers, body=req_body, cookies=cookies,
is_api="/api/" in full_path or full_path.startswith("/api/"),
preferred_proxy=preferred_proxy,
)
# ── v5: Sticky proxy + P2C selection with semaphore ──────────────────
sticky_idx, had_sticky = get_sticky_proxy_idx(request)
final_proxy_idx = sticky_idx
tried_idxs: set[int] = set()
import random
is_fast = method in ("POST", "PUT", "PATCH") or "/users/" in full_path
request_timeout = FAST_REQUEST_TIMEOUT if is_fast else NORMAL_REQUEST_TIMEOUT
# Try with sticky first, then P2C fallback
has_session = bool(cookies) # Stateful request: has user cookies
session_cookie_val = cookies.get("_otwarchive_session", "")[:80]
logger.info(f"Request {method} {full_path} | cookies={list(cookies.keys())} | session={session_cookie_val}... | has_session={has_session}")
async with pool.concurrency_limiter:
# First attempt: sticky proxy
result = None
sticky_proxy = pool.sticky_select(sticky_idx, tried_idxs)
if sticky_proxy:
sticky_proxy.inflight += 1
tried_idxs.add(sticky_proxy.idx)
try:
result = await fetch_url(
url=ao3_url, method=method,
proxy=sticky_proxy,
headers=client_headers, body=req_body, cookies=cookies,
is_api="/api/" in full_path,
)
if result["success"]:
final_proxy_idx = sticky_proxy.idx
sticky_proxy.mark_success(result.get("elapsed", 0))
sticky_proxy.inflight -= 1
elif result.get("is_challenge"):
sticky_proxy.mark_challenged()
sticky_proxy.inflight -= 1
else:
status = result.get("status", 0)
if status in (403, 525):
sticky_proxy.mark_blocked()
else:
sticky_proxy.mark_failure(is_network=True)
sticky_proxy.inflight -= 1
result = None # Force retry
except Exception:
if sticky_proxy:
sticky_proxy.mark_failure(is_network=True)
sticky_proxy.inflight -= 1
result = None
# Retry loop with P2C — but NEVER for stateful requests
# If the sticky proxy failed on a request with cookies, switching
# proxies breaks CSRF/session affinity. Forward CF challenge instead.
if (not result or not result["success"]) and not has_session:
max_retries = 5
for attempt in range(max_retries):
if attempt > 0:
await _sleep(0.1 * (2 ** min(attempt, 3)))
proxy = pool.p2c_select(exclude_idxs=tried_idxs)
if not proxy:
break
proxy.inflight += 1
tried_idxs.add(proxy.idx)
try:
result = await fetch_url(
url=ao3_url, method=method,
proxy=proxy,
headers=client_headers, body=req_body, cookies=cookies,
is_api="/api/" in full_path,
)
if result["success"]:
final_proxy_idx = proxy.idx
proxy.mark_success(result.get("elapsed", 0))
proxy.inflight -= 1
break
elif result.get("is_challenge"):
proxy.mark_challenged()
proxy.inflight -= 1
else:
status = result.get("status", 0)
if status in (403, 525):
proxy.mark_blocked()
elif status > 0:
proxy.mark_failure(is_network=False)
else:
proxy.mark_failure(is_network=True)
proxy.inflight -= 1
result = None
except Exception:
proxy.mark_failure(is_network=True)
proxy.inflight -= 1
result = None
elapsed = time.time() - start_time
# ── v4: CF Challenge handling ──────────────────────────────────────────
if result.get("is_challenge") and result.get("challenge_body"):
challenge_proxy = result.get("challenge_proxy", "unknown")
challenge_body = result["challenge_body"]
# ── All proxies exhausted — outage ───────────────────────────────────
if not result or not result["success"]:
if result and result.get("is_challenge") and result.get("challenge_body"):
challenge_proxy = result.get("challenge_proxy", "unknown")
token = _make_challenge_token()
_store_challenge(token, method, ao3_url, client_headers,
req_body or b"", cookies, challenge_proxy)
rewritten_challenge = _rewrite_challenge_page(
result["challenge_body"], challenge_proxy, token
)
logger.warning(f"CF Challenge via {challenge_proxy}, sending to browser")
collector = get_stats_collector()
collector.log_request(method=method, path=full_path, status=503,
elapsed=elapsed, cached=False,
proxy_host=challenge_proxy, client_ip=client_ip)
resp = HTMLResponse(
content=rewritten_challenge,
status_code=200, # 200 so browser renders and executes CF JS
headers={
"X-CF-Challenge": "true",
"X-CF-Challenge-Proxy": challenge_proxy,
},
)
resp.set_cookie(
key="_cf_token", value=token,
path="/", max_age=CHALLENGE_TOKEN_TTL,
httponly=False, samesite="lax",
)
resp.delete_cookie("cf_clearance", path="/")
add_cors(resp)
return resp
# Generate challenge token for proxy affinity
token = _make_challenge_token()
_store_challenge(token, method, ao3_url, client_headers,
req_body or b"", cookies, challenge_proxy)
# Check if we hit outage threshold
if pool.pool_unavailable_ratio() >= OUTAGE_THRESHOLD:
logger.error(f"OUTAGE: {pool.pool_unavailable_ratio():.1%} proxies unavailable")
return HTMLResponse(
content=_outage_page(), status_code=503
)
# Rewrite challenge page for user's browser
rewritten_challenge = _rewrite_challenge_page(challenge_body, challenge_proxy, token)
logger.warning(
f"CF Challenge detected via {challenge_proxy} for {ao3_url[:80]}. "
f"Sending challenge to user browser (token={token[:8]}...)"
)
collector = get_stats_collector()
collector.log_request(method=method, path=full_path, status=503,
elapsed=elapsed, cached=False,
proxy_host=challenge_proxy, client_ip=client_ip)
# Return challenge page with 503 status + token cookie
resp = HTMLResponse(
content=rewritten_challenge,
status_code=503,
headers={
"X-CF-Challenge": "true",
"X-CF-Challenge-Proxy": challenge_proxy,
"Retry-After": "5",
},
)
resp.set_cookie(
key="_cf_token", value=token,
path="/", max_age=CHALLENGE_TOKEN_TTL,
httponly=False, # JS needs to read it
samesite="lax",
)
resp.delete_cookie("cf_clearance", path="/") # Clear stale clearance
add_cors(resp)
return resp
# ── Standard failure ───────────────────────────────────────────────────
if not result["success"]:
logger.error(f"Failed to fetch {ao3_url[:80]}: {result.get('error', 'unknown')}")
logger.error(f"Failed: {ao3_url[:80]}: all retries exhausted")
collector = get_stats_collector()
collector.log_request(method=method, path=full_path, status=502,
elapsed=elapsed, cached=False, client_ip=client_ip)
return HTMLResponse(content=_transient_error_page(), status_code=502)
error_html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>AO3 Mirror - 暂时不可用</title>
<style>
body {{ font-family: sans-serif; text-align: center; padding: 50px; background: #0f0f1a; color: #e0e0e0; }}
h1 {{ color: #990000; }}
.card {{ background: #1a1a2e; border-radius: 10px; padding: 30px; max-width: 500px; margin: 30px auto; border: 1px solid #2a2a40; }}
.btn {{ display: inline-block; padding: 10px 24px; background: #990000; color: white; text-decoration: none; border-radius: 6px; margin-top: 15px; }}
</style></head>
<body>
<div class="card">
<h1>🔄 正在尝试连接 AO3</h1>
<p>镜像站正在尝试通过代理重新连接 AO3 服务器。</p>
<p style="color: #888; font-size: 0.9em;">请稍后刷新页面重试。</p>
<a class="btn" href="/" onclick="location.reload()">刷新页面</a>
</div>
</body>
</html>"""
return HTMLResponse(content=error_html, status_code=502)
# ── Success ────────────────────────────────────────────────────────────
# ── Success ──────────────────────────────────────────────────────────
ao3_status = result["status"]
ao3_headers = result.get("headers", {})
ao3_headers_raw = result.get("headers_raw", [])
raw_body = result.get("body", b"")
content_type = ao3_headers.get("Content-Type", "")
content_type = ao3_headers.get("Content-Type", ao3_headers.get("content-type", ""))
rewritten_body = rewrite_body(raw_body, content_type)
# Inject Service Worker into HTML responses
if "text/html" in content_type:
rewritten_body = inject_service_worker(rewritten_body)
rewritten_headers = rewrite_response_headers(ao3_headers)
final_headers = filter_response_headers(rewritten_headers)
# Remove Set-Cookie from dict — multi-value Set-Cookie from raw below
final_headers.pop("Set-Cookie", None)
final_headers.pop("set-cookie", None)
final_headers["X-Cache"] = "MISS"
# Cache successful GET responses
if method == "GET" and 200 <= ao3_status < 400:
ttl = get_ttl_for_path(full_path)
cache.set(ao3_url, rewritten_body, rewritten_headers, ao3_status, ttl=ttl)
# Cache successful static GET responses (disk MD5 cache, like go3)
if method == "GET" and ao3_status == 200 and is_static_path(full_path):
cache.set(ao3_url, rewritten_body, rewritten_headers, ao3_status)
# Handle redirects
if 300 <= ao3_status < 400 and "location" in rewritten_headers:
@@ -624,27 +779,94 @@ h1 {{ color: #990000; }}
client_ip=client_ip)
resp = Response(content=rewritten_body, status_code=ao3_status, headers=final_headers)
# Forward Set-Cookie from AO3 (user session cookies + cf_clearance)
for k, v in ao3_headers.items():
if k.lower() == "set-cookie":
v_rewritten = v.replace("domain=archiveofourown.org",
f"domain={MIRROR_DOMAIN}")
v_rewritten = v_rewritten.replace("domain=.archiveofourown.org",
f"domain=.{MIRROR_DOMAIN}")
resp.headers.add("Set-Cookie", v_rewritten)
# Append ALL Set-Cookie headers from raw (preserves multi-value — pitfall #18a)
# Filter: strip AO3 CF cookies — our own CF edge handles these
SKIP_COOKIES = {"__cf_bm", "_cfuvid"}
if ao3_headers_raw:
rewritten_raw = rewrite_response_headers_raw(ao3_headers_raw)
for key, value in rewritten_raw:
if key.lower() == "set-cookie":
cookie_name = value.split("=")[0].split(";")[0].strip()
if cookie_name in SKIP_COOKIES:
continue
logger.info(f"[SET_COOKIE_FWD] {value[:120]}...")
resp.headers.append("Set-Cookie", value)
# Set sticky session cookie
resp.set_cookie(
key=STICKY_COOKIE_NAME,
value=str(final_proxy_idx),
path="/",
httponly=True,
samesite="lax",
)
add_cors(resp)
return resp
# ─── Error pages ───────────────────────────────────────────────────────────
def _outage_page() -> str:
return """<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>AO3 Mirror - 暂时不可用</title>
<style>
body{font-family:sans-serif;text-align:center;padding:50px;background:#0f0f1a;color:#e0e0e0}
h1{color:#990000}
.card{background:#1a1a2e;border-radius:10px;padding:30px;max-width:500px;margin:30px auto;border:1px solid #2a2a40}
.btn{display:inline-block;padding:10px 24px;background:#990000;color:#fff;text-decoration:none;border-radius:6px;margin-top:15px}
</style></head>
<body>
<div class="card">
<h1>⚠️ AO3 暂时不可用</h1>
<p>所有代理目前都无法连接 AO3。可能 AO3 正在维护或大规模封锁。</p>
<p style="color:#888">请稍后重试。如果持续出现,请切换到数据流量。</p>
<a class="btn" href="/" onclick="location.reload()">刷新页面</a>
</div>
</body>
</html>"""
def _transient_error_page() -> str:
return """<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>AO3 Mirror - 连接失败</title>
<style>
body{font-family:sans-serif;text-align:center;padding:50px;background:#0f0f1a;color:#e0e0e0}
h1{color:#990000}
.card{background:#1a1a2e;border-radius:10px;padding:30px;max-width:500px;margin:30px auto;border:1px solid #2a2a40}
.btn{display:inline-block;padding:10px 24px;background:#990000;color:#fff;text-decoration:none;border-radius:6px;margin-top:15px}
</style></head>
<body>
<div class="card">
<h1>🔄 正在尝试连接 AO3</h1>
<p>镜像站正在通过代理重新连接 AO3 服务器。</p>
<p style="color:#888">请稍后刷新页面重试。</p>
<a class="btn" href="/" onclick="location.reload()">刷新页面</a>
</div>
</body>
</html>"""
# ─── Helper ────────────────────────────────────────────────────────────────
async def _sleep(seconds: float):
import asyncio
await asyncio.sleep(seconds)
# ─── Startup / Shutdown ────────────────────────────────────────────────────
@app.on_event("startup")
async def startup():
logger.info("AO3 Mirror backend v4 starting up...")
get_proxy_pool()
logger.info("AO3 Mirror backend v5 starting up...")
pool = get_proxy_pool()
get_cache()
get_stats_collector()
logger.info("AO3 Mirror backend v4 started (cookie-aware + CF challenge solving)")
logger.info(f"AO3 Mirror backend v5 started ({len(pool.proxies)} proxies, "
f"P2C selection, sticky sessions, SW deploy)")
@app.on_event("shutdown")
@@ -660,7 +882,7 @@ async def shutdown():
if __name__ == "__main__":
port = int(os.environ.get("PORT", "8080"))
workers = int(os.environ.get("WORKERS", "4"))
logger.info(f"Starting AO3 Mirror v4 on port {port} with {workers} workers")
logger.info(f"Starting AO3 Mirror v5 on port {port} with {workers} workers")
uvicorn.run(
"app:app",