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:
commit
d10009d639
72 files changed
+18752
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Registration service package."""
|
||||
@@ -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
|
||||
)
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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})'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in new issue
Block a user