Initial commit: grok-free-register-oss

Open-source Grok free registration CLI, xai_enroller auth pipeline,
local auth service, tests and docs.
This commit is contained in:
chaos committed 2026-07-16 21:04:05 +08:00
commit d10009d639
72 files changed
+18752

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Registration service package."""
+395
View File
@@ -0,0 +1,395 @@
"""Cloudflare clearance prewarm via external FlareSolverr.
This module does NOT run FlareSolverr. It only calls an already-deployed
FlareSolverr instance (default http://127.0.0.1:8191).
Proxy roles (do not mix):
- REGISTER_PROXY / HTTP_PROXY / HTTPS_PROXY
→ grok-free-register Playwright + httpx egress (this process)
- CLEARANCE_PROXY
→ optional proxy *inside* FlareSolverr's browser (must be reachable
from the FS *container*, e.g. http://privoxy:8118)
- FLARESOLVERR_URL
→ host-side URL to reach FS (http://127.0.0.1:8191)
Clearance cookies are only valid on the same egress path they were minted on.
If REGISTER_PROXY points at host Privoxy→WARP, set CLEARANCE_PROXY to the
docker-internal privoxy URL so FS solves on the same WARP exit.
"""
from __future__ import annotations
import json
import os
import threading
import time
import urllib.error
import urllib.request
from typing import Any
from urllib.parse import urlparse
# CF-fronted x.ai family roots (no path suffix; path pages share host clearance)
DEFAULT_CLEARANCE_URLS = (
"https://accounts.x.ai",
"https://x.ai",
"https://status.x.ai",
"https://console.x.ai",
"https://auth.x.ai",
)
_lock = threading.RLock()
_cache: dict[str, Any] = {
"cookies": [], # Playwright cookie dicts
"user_agent": "",
"fetched_at": 0.0,
"hosts": [],
"last_error": "",
}
def _env_bool(name: str, default: bool = False) -> bool:
raw = str(os.environ.get(name, "")).strip().lower()
if not raw:
return default
if raw in {"1", "true", "yes", "on"}:
return True
if raw in {"0", "false", "no", "off"}:
return False
return default
def _env_int(name: str, default: int) -> int:
try:
return int(str(os.environ.get(name, "")).strip() or default)
except (TypeError, ValueError):
return default
def clearance_enabled() -> bool:
return _env_bool("CLEARANCE_ENABLED", False)
def flaresolverr_url() -> str:
return (os.environ.get("FLARESOLVERR_URL") or "http://127.0.0.1:8191").strip().rstrip("/")
def clearance_timeout_sec() -> int:
return max(10, _env_int("CLEARANCE_TIMEOUT_SEC", 60))
def clearance_refresh_sec() -> int:
return max(60, _env_int("CLEARANCE_REFRESH_SEC", 3000))
def clearance_urls() -> list[str]:
raw = (os.environ.get("CLEARANCE_URLS") or "").strip()
if not raw:
return list(DEFAULT_CLEARANCE_URLS)
urls = []
for part in raw.split(","):
item = part.strip()
if not item:
continue
if "://" not in item:
item = "https://" + item
# strip path/query — host-level prewarm only
parsed = urlparse(item)
if parsed.scheme and parsed.netloc:
urls.append(f"{parsed.scheme}://{parsed.netloc}")
else:
urls.append(item.rstrip("/"))
# de-dupe preserve order
seen = set()
out = []
for u in urls:
if u not in seen:
seen.add(u)
out.append(u)
return out or list(DEFAULT_CLEARANCE_URLS)
def register_proxy_url() -> str:
"""Egress proxy for *this* process (Playwright / httpx)."""
for key in ("REGISTER_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY"):
value = (os.environ.get(key) or "").strip()
if value:
return value
return ""
def clearance_proxy_url() -> str:
"""Proxy for FlareSolverr browser (container-reachable). Empty = FS direct egress."""
explicit = (os.environ.get("CLEARANCE_PROXY") or "").strip()
if explicit:
return explicit
# Do NOT auto-forward host REGISTER_PROXY into FS — 127.0.0.1 inside
# the container is not the host. Operator must set CLEARANCE_PROXY when
# minting on WARP (e.g. http://privoxy:8118).
return ""
def playwright_proxy_settings() -> dict[str, str] | None:
"""Playwright launch/context proxy dict, or None for direct."""
url = register_proxy_url()
if not url:
return None
return {"server": url}
def httpx_proxy_mounts() -> str | None:
"""Single proxy URL for httpx.AsyncClient(proxy=...), or None."""
return register_proxy_url() or None
def _host_from_url(url: str) -> str:
try:
return (urlparse(url).hostname or "").lower()
except Exception:
return ""
def _fs_post(payload: dict[str, Any], timeout: float) -> dict[str, Any]:
endpoint = f"{flaresolverr_url()}/v1"
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
endpoint,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as response:
raw = response.read()
return json.loads(raw.decode("utf-8"))
def _cookie_to_playwright(item: dict[str, Any], fallback_host: str) -> dict[str, Any] | None:
name = str(item.get("name") or "").strip()
if not name:
return None
value = str(item.get("value") or "")
domain = str(item.get("domain") or "").strip()
if not domain:
domain = fallback_host
if domain and not domain.startswith(".") and domain.count(".") >= 1:
# keep host-only cookies host-only; CF often uses .x.ai
pass
path = str(item.get("path") or "/") or "/"
cookie: dict[str, Any] = {
"name": name,
"value": value,
"domain": domain,
"path": path,
}
if "httpOnly" in item:
cookie["httpOnly"] = bool(item.get("httpOnly"))
if "secure" in item:
cookie["secure"] = bool(item.get("secure"))
else:
cookie["secure"] = True
same_site = item.get("sameSite") or item.get("same_site")
if same_site:
# Playwright: Strict | Lax | None
normalized = str(same_site).capitalize()
if normalized.lower() == "none":
normalized = "None"
if normalized in {"Strict", "Lax", "None"}:
cookie["sameSite"] = normalized
expires = item.get("expires")
if expires is not None:
try:
exp = float(expires)
# FS may return ms or session -1
if exp > 0:
if exp > 1e12:
exp = exp / 1000.0
cookie["expires"] = exp
except (TypeError, ValueError):
pass
return cookie
def _merge_cookies(existing: list[dict[str, Any]], incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
index: dict[tuple[str, str, str], int] = {}
merged: list[dict[str, Any]] = []
for cookie in existing:
key = (
str(cookie.get("name") or ""),
str(cookie.get("domain") or ""),
str(cookie.get("path") or "/"),
)
index[key] = len(merged)
merged.append(cookie)
for cookie in incoming:
key = (
str(cookie.get("name") or ""),
str(cookie.get("domain") or ""),
str(cookie.get("path") or "/"),
)
if key in index:
merged[index[key]] = cookie
else:
index[key] = len(merged)
merged.append(cookie)
return merged
def prewarm_clearance(*, force: bool = False) -> dict[str, Any]:
"""Hit FlareSolverr for each CLEARANCE_URLS host; cache Playwright cookies.
Safe to call when disabled — returns status without network.
"""
if not clearance_enabled():
return {
"enabled": False,
"ok": True,
"skipped": True,
"message": "CLEARANCE_ENABLED=0",
"cookies": 0,
}
with _lock:
age = time.time() - float(_cache.get("fetched_at") or 0)
if (
not force
and _cache.get("cookies")
and age < clearance_refresh_sec()
):
return {
"enabled": True,
"ok": True,
"cached": True,
"age_sec": round(age, 1),
"cookies": len(_cache["cookies"]),
"hosts": list(_cache.get("hosts") or []),
"user_agent": _cache.get("user_agent") or "",
}
urls = clearance_urls()
timeout = float(clearance_timeout_sec())
fs_proxy = clearance_proxy_url()
max_timeout_ms = int(timeout * 1000)
merged: list[dict[str, Any]] = []
user_agent = ""
hosts_ok: list[str] = []
errors: list[str] = []
per_host: list[dict[str, Any]] = []
for url in urls:
host = _host_from_url(url)
payload: dict[str, Any] = {
"cmd": "request.get",
"url": url,
"maxTimeout": max_timeout_ms,
}
if fs_proxy:
payload["proxy"] = {"url": fs_proxy}
t0 = time.time()
try:
data = _fs_post(payload, timeout=timeout + 15)
elapsed = round(time.time() - t0, 2)
if str(data.get("status") or "").lower() != "ok":
errors.append(f"{host}: status={data.get('status')} msg={data.get('message')}")
per_host.append({"host": host, "ok": False, "elapsed": elapsed, "error": data.get("message")})
continue
solution = data.get("solution") if isinstance(data.get("solution"), dict) else {}
raw_cookies = solution.get("cookies") or []
pw_cookies = []
if isinstance(raw_cookies, list):
for item in raw_cookies:
if not isinstance(item, dict):
continue
converted = _cookie_to_playwright(item, host)
if converted:
pw_cookies.append(converted)
merged = _merge_cookies(merged, pw_cookies)
ua = str(solution.get("userAgent") or "").strip()
if ua:
user_agent = ua
has_cf = any(c.get("name") == "cf_clearance" for c in pw_cookies)
hosts_ok.append(host)
per_host.append(
{
"host": host,
"ok": True,
"elapsed": elapsed,
"http": solution.get("status"),
"cookies": len(pw_cookies),
"cf_clearance": has_cf,
}
)
except Exception as exc: # noqa: BLE001 — surface to operator log
elapsed = round(time.time() - t0, 2)
errors.append(f"{host}: {exc}")
per_host.append({"host": host, "ok": False, "elapsed": elapsed, "error": str(exc)})
with _lock:
if merged:
_cache["cookies"] = merged
_cache["user_agent"] = user_agent
_cache["fetched_at"] = time.time()
_cache["hosts"] = hosts_ok
_cache["last_error"] = "; ".join(errors)
elif errors:
_cache["last_error"] = "; ".join(errors)
return {
"enabled": True,
"ok": bool(merged) or not errors,
"cached": False,
"cookies": len(merged),
"hosts": hosts_ok,
"user_agent": user_agent,
"errors": errors,
"detail": per_host,
"flaresolverr": flaresolverr_url(),
"clearance_proxy": fs_proxy or "(direct)",
"register_proxy": register_proxy_url() or "(direct)",
}
def cached_playwright_cookies() -> list[dict[str, Any]]:
with _lock:
return [dict(c) for c in (_cache.get("cookies") or [])]
def cached_user_agent() -> str:
with _lock:
return str(_cache.get("user_agent") or "")
async def apply_clearance_to_context(context) -> int:
"""Inject cached CF cookies into a Playwright BrowserContext. Returns count."""
cookies = cached_playwright_cookies()
if not cookies or context is None:
return 0
try:
await context.add_cookies(cookies)
return len(cookies)
except Exception:
return 0
def format_prewarm_log(result: dict[str, Any]) -> str:
if result.get("skipped"):
return "[clearance] 未启用 (CLEARANCE_ENABLED=0)"
if result.get("cached"):
return (
f"[clearance] 复用缓存 cookies={result.get('cookies')} "
f"age={result.get('age_sec')}s hosts={','.join(result.get('hosts') or [])}"
)
detail = result.get("detail") or []
parts = []
for row in detail:
host = row.get("host")
if row.get("ok"):
cf = "cf+" if row.get("cf_clearance") else "cf-"
parts.append(f"{host}:{row.get('elapsed')}s/{cf}")
else:
parts.append(f"{host}:FAIL")
err = result.get("errors") or []
suffix = f" errors={len(err)}" if err else ""
return (
f"[clearance] 预热完成 cookies={result.get('cookies')} "
f"fs={result.get('flaresolverr')} reg_proxy={result.get('register_proxy')} "
f"fs_proxy={result.get('clearance_proxy')} | " + " ".join(parts) + suffix
)
View File
Whitespace-only changes.
+129
View File
@@ -0,0 +1,129 @@
"""Local admission gates for CSP workers.
AdmissionGate is not a scheduler: it never chooses a role and never moves
resources. It only lets producers reserve local production intent against
inventory watermarks, so fixed S/P workers can self-throttle without a central
dispatcher.
"""
import asyncio
class _TProductionLease:
__slots__ = ("_gate", "_released")
def __init__(self, gate):
self._gate = gate
self._released = False
async def release(self):
if self._released:
return
self._released = True
async with self._gate._cond:
self._gate.t_in_progress -= 1
self._gate._cond.notify_all()
class _QBatchLease:
__slots__ = ("_gate", "count", "_remaining")
def __init__(self, gate, count):
self._gate = gate
self.count = count
self._remaining = count
async def release_one(self):
if self._remaining <= 0:
return
async with self._gate._cond:
if self._remaining <= 0:
return
self._remaining -= 1
self._gate.q_inflight -= 1
self._gate._cond.notify_all()
async def release_all(self):
if self._remaining <= 0:
return
async with self._gate._cond:
if self._remaining <= 0:
return
self._gate.q_inflight -= self._remaining
self._remaining = 0
self._gate._cond.notify_all()
class AdmissionGate:
"""Watermark-based producer admission for T and Q.
T production uses high/low hysteresis because local T generation can be
heavy and should not keep filling an already sufficient inventory.
Q production reserves a batch by current demand:
q_high - q_depth - q_inflight
capped by the worker's max batch. Each reservation is released when the
corresponding pending Q requests have reached terminal state.
"""
def __init__(self, inventory, *, t_low, t_high, q_low, q_high):
if t_low < 0 or q_low < 0:
raise ValueError("low watermarks must be non-negative")
if t_high <= 0 or q_high <= 0:
raise ValueError("high watermarks must be positive")
if t_low > t_high:
raise ValueError("t_low must be <= t_high")
if q_low > q_high:
raise ValueError("q_low must be <= q_high")
self.inventory = inventory
self.t_low = t_low
self.t_high = t_high
self.q_low = q_low
self.q_high = q_high
self.t_in_progress = 0
self.q_inflight = 0
self._t_paused = False
self._q_paused = False
self._cond = asyncio.Condition()
async def acquire_t_production(self):
"""Reserve one T production attempt."""
async with self._cond:
while True:
total = self.inventory.t_depth + self.t_in_progress
if total >= self.t_high:
self._t_paused = True
if self._t_paused and total <= self.t_low:
self._t_paused = False
if not self._t_paused and total < self.t_high:
self.t_in_progress += 1
self._cond.notify_all()
return _TProductionLease(self)
await self._cond.wait()
async def acquire_q_batch(self, *, max_batch):
"""Reserve up to max_batch pending Q requests based on current demand."""
if max_batch <= 0:
raise ValueError("max_batch must be positive")
async with self._cond:
while True:
total = self.inventory.q_depth + self.q_inflight
if total >= self.q_high:
self._q_paused = True
if self._q_paused and total <= self.q_low:
self._q_paused = False
demand = self.q_high - total
if not self._q_paused and demand > 0:
count = min(max_batch, demand)
self.q_inflight += count
if self.inventory.q_depth + self.q_inflight >= self.q_high:
self._q_paused = True
self._cond.notify_all()
return _QBatchLease(self, count)
await self._cond.wait()
async def notify_changed(self):
"""Wake waiters after inventory depth changes."""
async with self._cond:
self._cond.notify_all()
+61
View File
@@ -0,0 +1,61 @@
"""
ResourceEnvelope — T/Q 资源实体与库存 slot 的生命周期绑定
slot 获取与 envelope 创建必须通过 create_with_slot() 完成。
release_slot_once() 幂等:重复调用只释放一次。
"""
import time
import asyncio
class ResourceEnvelope:
"""绑定一个 T/Q 资源值与其对应的库存 slot。"""
__slots__ = ('kind', 'value', 'slot_sem', '_released', 'created_at', 'expires_at', 'meta')
def __init__(self, kind, value, slot_sem, *, created_at, expires_at=None, meta=None):
self.kind = kind # 'T' or 'Q'
self.value = value # T: turnstile token str; Q: {'email','password','code'}
self.slot_sem = slot_sem # T_Slot_Sem or Q_Slot_Sem
self._released = False
self.created_at = created_at
self.expires_at = expires_at
self.meta = meta or {}
@classmethod
async def create_with_slot(cls, kind, value, slot_sem, *, expires_at=None, meta=None):
"""先获取库存 slot,再构造 envelope。slot 获取失败则抛异常,不创建 envelope。"""
await slot_sem.acquire()
try:
return cls(
kind, value, slot_sem,
created_at=time.time(),
expires_at=expires_at,
meta=meta,
)
except BaseException:
slot_sem.release()
raise
def release_slot_once(self, reason=''):
"""幂等释放 slot。重复调用安全。"""
if not self._released:
self._released = True
self.slot_sem.release()
def discard(self, reason=''):
"""资源未被消费,释放库存占用。"""
self.release_slot_once(reason=f'discard:{reason}')
def is_expired(self, now=None):
"""检查资源是否过期。"""
if self.expires_at is None:
return False
return (now or time.time()) > self.expires_at
@property
def released(self):
return self._released
def __repr__(self):
return f'Envelope({self.kind}, released={self._released})'
+169
View File
@@ -0,0 +1,169 @@
"""
Inventory — 唯一库存门面 + PairLease
put_t / put_q: 所有权从 Worker 转移到 Inventory
claim_pair(): 等待完整 T/Q pair,通过 PairLease 原子转移所有权
内部单锁 + Condition,不做分片,不做无锁。
"""
import asyncio
import time
from collections import deque
from .envelope import ResourceEnvelope
class PairLease:
"""claim_pair 成功后的 pair 所有权对象。
用法:
async with inventory.claim_pair() as pair:
t_val, q_val = pair.t.value, pair.q.value
...
离开上下文时无论成功/失败/取消,都核销 T/Q 并释放 slot。
"""
__slots__ = ('t', 'q', '_inventory')
def __init__(self, t_env, q_env, inventory):
self.t = t_env
self.q = q_env
self._inventory = inventory
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
# 第一版策略: 无论结果如何,均核销 T/Q
self.t.release_slot_once(reason='pair_settle')
self.q.release_slot_once(reason='pair_settle')
# 通知可能在等待的 claim_pair
async with self._inventory._lock:
self._inventory._cond.notify_all()
return False
class Inventory:
"""T/Q 库存门面。
内部维护两个 deque 和一个 Lock+Condition。
put_t/put_q 在锁内 O(1) 操作;claim_pair 在锁外等待 Condition。
"""
def __init__(self, metrics=None):
self._t_buf = deque()
self._q_buf = deque()
self._lock = asyncio.Lock()
self._cond = asyncio.Condition(self._lock)
self._metrics = metrics
# ── 入库 ──
async def put_t(self, env: ResourceEnvelope):
"""将 T envelope 入库。调用前 env 属于 Worker,成功后属于 Inventory。"""
async with self._lock:
now = time.time()
if env.is_expired(now):
env.discard(reason='expired_on_put')
if self._metrics:
self._metrics.t_expired += 1
return
self._cleanup_expired(now)
self._t_buf.append(env)
if self._metrics:
self._metrics.t_admitted += 1
self._cond.notify_all()
async def put_q(self, env: ResourceEnvelope):
"""将 Q envelope 入库。调用前 env 属于 Worker,成功后属于 Inventory。"""
async with self._lock:
now = time.time()
if env.is_expired(now):
env.discard(reason='expired_on_put')
if self._metrics:
self._metrics.q_expired += 1
return
self._cleanup_expired(now)
self._q_buf.append(env)
if self._metrics:
self._metrics.q_admitted += 1
self._cond.notify_all()
# ── Claim ──
def claim_pair(self):
"""返回 PairLease 的 async context manager。
等待期间可取消,取消不弹出资源。成功后 T/Q 原子弹出并转移给 PairLease。
"""
return _PairClaim(self, self._metrics)
# ── 内部 ──
def _try_pop_pair(self):
"""在锁内尝试弹出一对未过期的 T/Q。返回 (t_env, q_env) 或 None。"""
now = time.time()
# 清理过期
self._cleanup_expired(now)
if self._t_buf and self._q_buf:
t_env = self._t_buf.popleft()
q_env = self._q_buf.popleft()
return t_env, q_env
return None
def _cleanup_expired(self, now):
"""lazy cleanup: 清理已过期的 T/Q 并释放 slot。锁内调用。"""
self._cleanup_buf(self._t_buf, now, 't_expired')
self._cleanup_buf(self._q_buf, now, 'q_expired')
def _cleanup_buf(self, buf, now, metric_name):
"""扫描完整库存,保留未过期实体的原始 FIFO 顺序。"""
kept = deque()
while buf:
env = buf.popleft()
if env.is_expired(now):
env.discard(reason='expired_in_inventory')
if self._metrics:
setattr(self._metrics, metric_name, getattr(self._metrics, metric_name) + 1)
else:
kept.append(env)
buf.extend(kept)
@property
def t_depth(self):
return len(self._t_buf)
@property
def q_depth(self):
return len(self._q_buf)
class _PairClaim:
"""claim_pair() 返回的 async context manager。"""
__slots__ = ('_inventory', '_pair', '_metrics')
def __init__(self, inventory, metrics):
self._inventory = inventory
self._pair = None
self._metrics = metrics
async def __aenter__(self):
inv = self._inventory
async with inv._lock:
# 循环等待直到有 pair 或被取消
while True:
pair = inv._try_pop_pair()
if pair is not None:
t_env, q_env = pair
self._pair = PairLease(t_env, q_env, inv)
if self._metrics:
self._metrics.pair_claimed += 1
return self._pair
# 没有 pair,等待 notify
await inv._cond.wait()
async def __aexit__(self, exc_type, exc, tb):
if self._pair is not None:
# PairLease.__aexit__ 处理核销
return await self._pair.__aexit__(exc_type, exc, tb)
return False
+215
View File
@@ -0,0 +1,215 @@
"""
Observer — 只读观测层
只记录指标、生成日志。不修改 Semaphore,不调度 Worker,不释放资源。
"""
import time
from collections import deque
class Metrics:
"""全局指标收集器。所有字段只在 asyncio 单线程中写入,无需加锁。"""
__slots__ = (
'start_time',
't_produced', 't_admitted', 't_claimed', 't_expired', 't_discarded',
't_solve_count', 't_solve_seconds', 't_solve_failed',
'solver_goto_seconds', 'solver_inject_seconds', 'solver_initial_seconds',
'solver_click_seconds', 'solver_wait_seconds', 'solver_reused_count',
'solver_visible_frame_count',
's_physical_count', 's_physical_wait_seconds', 's_physical_hold_seconds',
'p_physical_count', 'p_physical_wait_seconds', 'p_physical_hold_seconds',
'c_physical_count', 'c_physical_wait_seconds', 'c_physical_hold_seconds',
'p_email_create_count', 'p_email_create_seconds',
'p_page_prepare_count', 'p_page_prepare_seconds',
'p_send_count', 'p_send_seconds',
'c_page_acquire_count', 'c_page_acquire_seconds',
'c_verify_count', 'c_verify_seconds',
'c_register_count', 'c_register_seconds',
'c_hot_page_hits', 'c_hot_page_misses',
'q_sent', 'q_returned', 'q_admitted', 'q_claimed', 'q_expired', 'q_discarded',
'q_send_batches', 'q_send_batch_items',
'pair_claimed', 'pair_consumed_ok', 'pair_consumed_fail',
'success_count',
'registration_starts',
'_clock', 'started_monotonic', 'recent_success_times',
)
def __init__(self, clock=time.monotonic):
self._clock = clock
self.started_monotonic = clock()
self.recent_success_times = deque()
self.start_time = time.time()
# T 生命周期
self.t_produced = 0
self.t_admitted = 0
self.t_claimed = 0
self.t_expired = 0
self.t_discarded = 0
self.t_solve_count = 0
self.t_solve_seconds = 0.0
self.t_solve_failed = 0
self.solver_goto_seconds = 0.0
self.solver_inject_seconds = 0.0
self.solver_initial_seconds = 0.0
self.solver_click_seconds = 0.0
self.solver_wait_seconds = 0.0
self.solver_reused_count = 0
self.solver_visible_frame_count = 0
self.s_physical_count = 0
self.s_physical_wait_seconds = 0.0
self.s_physical_hold_seconds = 0.0
self.p_physical_count = 0
self.p_physical_wait_seconds = 0.0
self.p_physical_hold_seconds = 0.0
self.c_physical_count = 0
self.c_physical_wait_seconds = 0.0
self.c_physical_hold_seconds = 0.0
self.p_email_create_count = 0
self.p_email_create_seconds = 0.0
self.p_page_prepare_count = 0
self.p_page_prepare_seconds = 0.0
self.p_send_count = 0
self.p_send_seconds = 0.0
self.c_page_acquire_count = 0
self.c_page_acquire_seconds = 0.0
self.c_verify_count = 0
self.c_verify_seconds = 0.0
self.c_register_count = 0
self.c_register_seconds = 0.0
self.c_hot_page_hits = 0
self.c_hot_page_misses = 0
# Q 生命周期
self.q_sent = 0
self.q_returned = 0
self.q_admitted = 0
self.q_claimed = 0
self.q_expired = 0
self.q_discarded = 0
self.q_send_batches = 0
self.q_send_batch_items = 0
# Pair
self.pair_claimed = 0
self.pair_consumed_ok = 0
self.pair_consumed_fail = 0
# 成功数
self.success_count = 0
self.registration_starts = 0
def next_registration_task(self):
self.registration_starts += 1
return self.registration_starts
def record_success(self):
self.success_count += 1
self.recent_success_times.append(self._clock())
def five_minute_success_rate(self):
now = self._clock()
cutoff = now - 300.0
while self.recent_success_times and self.recent_success_times[0] < cutoff:
self.recent_success_times.popleft()
if not self.recent_success_times:
return None if self.success_count == 0 else 0.0
elapsed = max(1.0, min(300.0, now - self.started_monotonic))
return len(self.recent_success_times) * 60.0 / elapsed
def runtime_average_success_rate(self):
if self.success_count == 0:
return None
elapsed = max(1.0, self._clock() - self.started_monotonic)
return self.success_count * 60.0 / elapsed
def snapshot(self, inventory, sems):
"""生成一行监控日志。"""
elapsed = time.time() - self.start_time
rate = self.success_count / (elapsed / 60) if elapsed > 60 else 0
p_batch_avg = (
self.q_send_batch_items / self.q_send_batches
if self.q_send_batches else 0
)
t_solve_avg = (
self.t_solve_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_goto_avg = (
self.solver_goto_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_inject_avg = (
self.solver_inject_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_initial_avg = (
self.solver_initial_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_click_avg = (
self.solver_click_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_wait_avg = (
self.solver_wait_seconds / self.t_solve_count
if self.t_solve_count else 0
)
solver_reuse_ratio = (
self.solver_reused_count / self.t_solve_count
if self.t_solve_count else 0
)
solver_visible_ratio = (
self.solver_visible_frame_count / self.t_solve_count
if self.t_solve_count else 0
)
s_phys_wait, s_phys_hold = self._avg_pair(
self.s_physical_wait_seconds, self.s_physical_hold_seconds, self.s_physical_count
)
p_phys_wait, p_phys_hold = self._avg_pair(
self.p_physical_wait_seconds, self.p_physical_hold_seconds, self.p_physical_count
)
c_phys_wait, c_phys_hold = self._avg_pair(
self.c_physical_wait_seconds, self.c_physical_hold_seconds, self.c_physical_count
)
p_email_create = self._avg(self.p_email_create_seconds, self.p_email_create_count)
p_page_prepare = self._avg(self.p_page_prepare_seconds, self.p_page_prepare_count)
p_send_stage = self._avg(self.p_send_seconds, self.p_send_count)
c_page_acquire = self._avg(self.c_page_acquire_seconds, self.c_page_acquire_count)
c_verify = self._avg(self.c_verify_seconds, self.c_verify_count)
c_register = self._avg(self.c_register_seconds, self.c_register_count)
p_send_sem = sems.get("p_send")
admission = sems.get("admission")
p_send_part = f' p_send:{p_send_sem._value}' if p_send_sem is not None else ''
admission_part = (
f' t_prog:{admission.t_in_progress} q_inflight:{admission.q_inflight}'
if admission is not None else ''
)
return (
f'[*] T:{inventory.t_depth} Q:{inventory.q_depth} '
f'phys:{sems["physical"]._value}{p_send_part} t_slot:{sems["t_slot"]._value} '
f'q_slot:{sems["q_slot"]._value} q_pend:{sems["q_pending"]._value} '
f'p_batch:{p_batch_avg:.1f}{admission_part} '
f's_phys:{s_phys_wait:.2f}/{s_phys_hold:.2f} '
f'p_phys:{p_phys_wait:.2f}/{p_phys_hold:.2f} '
f'c_phys:{c_phys_wait:.2f}/{c_phys_hold:.2f} '
f'p_stage:{p_email_create:.2f}/{p_page_prepare:.2f}/{p_send_stage:.2f} '
f'c_stage:{c_page_acquire:.2f}/{c_verify:.2f}/{c_register:.2f} '
f'c_hot:{self.c_hot_page_hits}/{self.c_hot_page_misses} '
f't_solve_avg:{t_solve_avg:.1f} t_solve_fail:{self.t_solve_failed} '
f'solver_goto:{solver_goto_avg:.2f} solver_inject:{solver_inject_avg:.2f} '
f'solver_initial:{solver_initial_avg:.2f} solver_click:{solver_click_avg:.2f} '
f'solver_wait:{solver_wait_avg:.2f} solver_reuse:{solver_reuse_ratio:.2f} '
f'solver_visible:{solver_visible_ratio:.2f} '
f't_prod:{self.t_produced} t_adm:{self.t_admitted} t_exp:{self.t_expired} '
f'q_sent:{self.q_sent} q_ret:{self.q_returned} q_adm:{self.q_admitted} q_exp:{self.q_expired} '
f'pair:{self.pair_claimed} ok:{self.pair_consumed_ok} fail:{self.pair_consumed_fail} '
f'rate:{rate:.1f}/min #{self.success_count}'
)
@staticmethod
def _avg(total, count):
return total / count if count else 0
@staticmethod
def _avg_pair(wait_total, hold_total, count):
if not count:
return 0, 0
return wait_total / count, hold_total / count
+171
View File
@@ -0,0 +1,171 @@
"""
邮件接收 API 服务器
====================
接收 Cloudflare Email Routing 转发的邮件,供注册服务查询。
端点:
POST /webhook — Cloudflare 转发邮件到这里
GET /check/<email> — 查询某邮箱的验证码
GET /domains — 返回可用域名
GET /health — 健康检查
用法:
bash start.sh --email-service
EMAIL_DOMAIN=your.domain bash start.sh --email-service --port 8080
"""
import os, re, json, time, sys
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
from http.server import HTTPServer, ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from threading import Lock
# 配置
DEFAULT_DOMAIN = os.environ.get("EMAIL_DOMAIN", "")
DEFAULT_PORT = 8080
# 存储
emails = {} # {email_address: [{"code": "ABC123", "time": timestamp, "raw": "..."}]}
emails_lock = Lock()
# 清理过期邮件(5 分钟)
def cleanup_old():
now = time.time()
with emails_lock:
for addr in list(emails.keys()):
emails[addr] = [e for e in emails[addr] if now - e['time'] < 300]
if not emails[addr]:
del emails[addr]
def extract_code(text):
"""从邮件内容提取验证码(ABC-DEF 或 ABCDEF 格式)"""
# 格式1: ABC-DEF
m = re.search(r'>([A-Z0-9]{3}-[A-Z0-9]{3})<', text)
if m:
return m.group(1).replace('-', '')
# 格式2: 直接 6 位
m = re.search(r'>([A-Z0-9]{6})<', text)
if m:
return m.group(1)
# 格式3: 正文中的 6 位
m = re.search(r'\b([A-Z0-9]{3}-?[A-Z0-9]{3})\b', text)
if m:
return m.group(1).replace('-', '')
return None
class EmailHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == '/webhook':
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
data = json.loads(body)
except:
data = {}
# Cloudflare Email Routing 格式
to_addr = data.get('to', data.get('recipient', ''))
from_addr = data.get('from', data.get('sender', ''))
subject = data.get('subject', '')
text = data.get('text', '')
html = data.get('html', '')
# 提取验证码
content = f"{subject}\n{text}\n{html}"
code = extract_code(content)
if to_addr and code:
with emails_lock:
if to_addr not in emails:
emails[to_addr] = []
emails[to_addr].append({
'code': code,
'time': time.time(),
'from': from_addr,
'subject': subject
})
print(f'[+] {to_addr} code={code}', flush=True)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({"ok": True, "code": code}).encode())
else:
self.send_response(404)
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
if path == '/health':
self._json({"status": "ok", "emails": len(emails)})
elif path == '/domains':
self._json({"domains": [DEFAULT_DOMAIN]})
elif path.startswith('/check/'):
addr = path[7:] # 去掉 /check/
cleanup_old()
with emails_lock:
items = emails.get(addr, [])
if items:
# 返回最新的验证码
latest = items[-1]
self._json({"code": latest['code'], "from": latest['from']})
else:
self._json({"code": None})
elif path == '/list':
# 列出所有有邮件的地址(调试用)
cleanup_old()
with emails_lock:
result = {addr: len(msgs) for addr, msgs in emails.items()}
self._json(result)
else:
self.send_response(404)
self.end_headers()
def _json(self, data):
body = json.dumps(data).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
print(f"[HTTP] {args[0] if args else format}", flush=True)
def main():
global DEFAULT_DOMAIN
port = DEFAULT_PORT
domain = DEFAULT_DOMAIN
for i, arg in enumerate(sys.argv[1:]):
if arg == '--port' and i + 2 <= len(sys.argv):
port = int(sys.argv[i + 2])
if arg == '--domain' and i + 2 <= len(sys.argv):
domain = sys.argv[i + 2]
DEFAULT_DOMAIN = domain
print(f"[*] Email server starting on :{port}", flush=True)
print(f"[*] Domain: {domain}", flush=True)
print(f"[*] Webhook: http://0.0.0.0:{port}/webhook", flush=True)
print(f"[*] Check: http://localhost:{port}/check/<email>", flush=True)
server = ThreadingHTTPServer(('0.0.0.0', port), EmailHandler)
server.serve_forever()
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff