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:
3
xai_enroller/__init__.py
Normal file
3
xai_enroller/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Standalone xAI OAuth Device Flow enroller."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
111
xai_enroller/__main__.py
Normal file
111
xai_enroller/__main__.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Settings
|
||||
from .coordinator import EnrollmentCoordinator
|
||||
from .executors import HTTPProbeExecutor, PlaywrightExecutor
|
||||
from .protocol import XAIProfile, XAIProtocol
|
||||
from .sinks import CPAAuthFileSink
|
||||
from .sources import FileSourceAdapter, SQLiteSourceAdapter
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(description="Bounded xAI OAuth Device Flow enroller")
|
||||
parser.add_argument("--source", choices=("file", "sqlite"))
|
||||
parser.add_argument("--target", type=int)
|
||||
parser.add_argument("--concurrency", type=int)
|
||||
parser.add_argument("--retry-attempts", type=int)
|
||||
parser.add_argument("--executor", choices=("http", "playwright"))
|
||||
parser.add_argument("--sink", choices=("cpa",))
|
||||
return parser
|
||||
|
||||
|
||||
async def main_async(args=None):
|
||||
parsed = build_parser().parse_args(args)
|
||||
env = dict()
|
||||
if parsed.source:
|
||||
env["XAI_ENROLLER_SOURCE_KIND"] = parsed.source
|
||||
if parsed.target is not None:
|
||||
env["XAI_ENROLLER_TARGET"] = str(parsed.target)
|
||||
if parsed.concurrency is not None:
|
||||
env["XAI_ENROLLER_CONCURRENCY"] = str(parsed.concurrency)
|
||||
if parsed.retry_attempts is not None:
|
||||
env["XAI_ENROLLER_RETRY_ATTEMPTS"] = str(parsed.retry_attempts)
|
||||
if parsed.executor:
|
||||
env["XAI_ENROLLER_AUTH_EXECUTOR"] = parsed.executor
|
||||
if parsed.sink:
|
||||
env["XAI_ENROLLER_SINK"] = parsed.sink
|
||||
import os
|
||||
|
||||
merged = dict(os.environ)
|
||||
merged.update(env)
|
||||
settings = Settings.from_environ(merged)
|
||||
if settings.source_kind == "remote":
|
||||
raise ValueError("remote source requires the local auth service entrypoint")
|
||||
source = (
|
||||
FileSourceAdapter(settings.source_file)
|
||||
if settings.source_kind == "file"
|
||||
else SQLiteSourceAdapter(settings.source_db, settings.source_salt)
|
||||
)
|
||||
|
||||
# CF prewarm (external FlareSolverr) + register-side proxy for httpx/Playwright
|
||||
try:
|
||||
from grok_register.clearance import (
|
||||
format_prewarm_log,
|
||||
httpx_proxy_mounts,
|
||||
prewarm_clearance,
|
||||
)
|
||||
|
||||
print(format_prewarm_log(prewarm_clearance()))
|
||||
proxy_url = httpx_proxy_mounts()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[clearance] skip: {exc}")
|
||||
proxy_url = None
|
||||
|
||||
client = httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
|
||||
protocol = XAIProtocol(
|
||||
client,
|
||||
XAIProfile.default(),
|
||||
default_poll_interval=settings.poll_interval,
|
||||
)
|
||||
executor = (
|
||||
HTTPProbeExecutor(client)
|
||||
if settings.executor == "http"
|
||||
else PlaywrightExecutor(settings.concurrency)
|
||||
)
|
||||
sink = None
|
||||
sink_client = None
|
||||
if settings.sink == "cpa":
|
||||
sink_client = (
|
||||
httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
|
||||
)
|
||||
sink = CPAAuthFileSink(settings.cpa_base_url, settings.cpa_management_secret, sink_client)
|
||||
try:
|
||||
coordinator = EnrollmentCoordinator(
|
||||
source=source,
|
||||
protocol=protocol,
|
||||
executor=executor,
|
||||
sink=sink,
|
||||
ledger_path=settings.ledger_path,
|
||||
ledger_salt=settings.source_salt,
|
||||
concurrency=settings.concurrency,
|
||||
timeout=settings.timeout_sec,
|
||||
retry_attempts=settings.retry_attempts,
|
||||
)
|
||||
results = await coordinator.run(settings.target)
|
||||
for index, result in enumerate(results, 1):
|
||||
print(f"job {index}: {result.status.value} ({result.reason_code})")
|
||||
finally:
|
||||
await client.aclose()
|
||||
if sink_client:
|
||||
await sink_client.aclose()
|
||||
|
||||
|
||||
def main():
|
||||
asyncio.run(main_async())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1035
xai_enroller/auth_pipeline.py
Normal file
1035
xai_enroller/auth_pipeline.py
Normal file
File diff suppressed because it is too large
Load Diff
139
xai_enroller/config.py
Normal file
139
xai_enroller/config.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _int(env, key, default):
|
||||
try:
|
||||
return int(env.get(key, str(default)))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{key} must be an integer") from exc
|
||||
|
||||
|
||||
def _float(env, key, default):
|
||||
try:
|
||||
return float(env.get(key, str(default)))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{key} must be numeric") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
source_kind: str
|
||||
source_file: Path | None
|
||||
source_db: Path | None
|
||||
source_salt: bytes
|
||||
ledger_path: Path
|
||||
target: int = 1
|
||||
concurrency: int = 1
|
||||
retry_attempts: int = 0
|
||||
timeout_sec: float = 1800.0
|
||||
poll_interval: float = 5.0
|
||||
executor: str = "http"
|
||||
sink: str | None = None
|
||||
cpa_base_url: str | None = None
|
||||
cpa_management_secret: str | None = None
|
||||
local_auth_dir: Path | None = None
|
||||
|
||||
@classmethod
|
||||
def from_environ(cls, env=None):
|
||||
env = dict(os.environ if env is None else env)
|
||||
source_kind = env.get("XAI_ENROLLER_SOURCE_KIND", "file")
|
||||
if source_kind not in {"file", "sqlite", "remote"}:
|
||||
raise ValueError("source kind must be file, sqlite, or remote")
|
||||
source_file = Path(env["XAI_ENROLLER_SOURCE_FILE"]) if env.get("XAI_ENROLLER_SOURCE_FILE") else None
|
||||
source_db = Path(env["XAI_ENROLLER_SOURCE_DB"]) if env.get("XAI_ENROLLER_SOURCE_DB") else None
|
||||
if source_kind == "file" and source_file is None:
|
||||
raise ValueError("source file is required")
|
||||
if source_kind == "sqlite" and source_db is None:
|
||||
raise ValueError("source db is required")
|
||||
salt = env.get("XAI_ENROLLER_SOURCE_SALT")
|
||||
if not salt:
|
||||
raise ValueError("source salt is required")
|
||||
ledger_path = Path(env.get("XAI_ENROLLER_LEDGER_PATH", "xai-enroller-ledger.db"))
|
||||
target = _int(env, "XAI_ENROLLER_TARGET", 1)
|
||||
concurrency = _int(env, "XAI_ENROLLER_CONCURRENCY", 1)
|
||||
retry_attempts = _int(env, "XAI_ENROLLER_RETRY_ATTEMPTS", 0)
|
||||
timeout_sec = _float(env, "XAI_ENROLLER_TIMEOUT_SEC", 1800)
|
||||
poll_interval = _float(env, "XAI_ENROLLER_POLL_SEC", 5)
|
||||
executor = env.get("XAI_ENROLLER_AUTH_EXECUTOR", "http")
|
||||
sink = env.get("XAI_ENROLLER_SINK") or None
|
||||
if not 1 <= target <= 100:
|
||||
raise ValueError("target must be between 1 and 100")
|
||||
if not 1 <= concurrency <= 4:
|
||||
raise ValueError("concurrency must be between 1 and 4")
|
||||
if not 0 <= retry_attempts <= 3:
|
||||
raise ValueError("retry attempts must be between 0 and 3")
|
||||
if timeout_sec <= 0:
|
||||
raise ValueError("timeout must be positive")
|
||||
if poll_interval <= 0:
|
||||
raise ValueError("poll interval must be positive")
|
||||
if executor not in {"http", "playwright"}:
|
||||
raise ValueError("executor must be http or playwright")
|
||||
if sink not in {None, "cpa", "local"}:
|
||||
raise ValueError("sink must be cpa or local")
|
||||
cpa_url = env.get("XAI_ENROLLER_CPA_BASE_URL") or None
|
||||
cpa_secret = env.get("XAI_ENROLLER_CPA_MANAGEMENT_SECRET") or None
|
||||
local_auth_dir = (
|
||||
Path(env["XAI_ENROLLER_LOCAL_AUTH_DIR"]).expanduser()
|
||||
if env.get("XAI_ENROLLER_LOCAL_AUTH_DIR")
|
||||
else None
|
||||
)
|
||||
if sink == "cpa":
|
||||
if not cpa_url or not cpa_secret:
|
||||
raise ValueError("CPA base URL and management secret are required")
|
||||
parsed = urlparse(cpa_url)
|
||||
is_private = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
|
||||
if parsed.scheme != "https" and not is_private:
|
||||
raise ValueError("CPA base URL must use HTTPS")
|
||||
if sink == "local" and local_auth_dir is None:
|
||||
raise ValueError("local auth directory is required")
|
||||
return cls(
|
||||
source_kind=source_kind,
|
||||
source_file=source_file,
|
||||
source_db=source_db,
|
||||
source_salt=salt.encode(),
|
||||
ledger_path=ledger_path,
|
||||
target=target,
|
||||
concurrency=concurrency,
|
||||
retry_attempts=retry_attempts,
|
||||
timeout_sec=timeout_sec,
|
||||
poll_interval=poll_interval,
|
||||
executor=executor,
|
||||
sink=sink,
|
||||
cpa_base_url=cpa_url,
|
||||
cpa_management_secret=cpa_secret,
|
||||
local_auth_dir=local_auth_dir,
|
||||
)
|
||||
|
||||
def redacted_dict(self):
|
||||
return {
|
||||
"source_kind": self.source_kind,
|
||||
"source_file": str(self.source_file) if self.source_file else None,
|
||||
"source_db": str(self.source_db) if self.source_db else None,
|
||||
"ledger_path": str(self.ledger_path),
|
||||
"target": self.target,
|
||||
"concurrency": self.concurrency,
|
||||
"retry_attempts": self.retry_attempts,
|
||||
"timeout_sec": self.timeout_sec,
|
||||
"poll_interval": self.poll_interval,
|
||||
"executor": self.executor,
|
||||
"sink": self.sink,
|
||||
"cpa_base_url": self.cpa_base_url,
|
||||
"local_auth_dir": str(self.local_auth_dir) if self.local_auth_dir else None,
|
||||
}
|
||||
|
||||
|
||||
def require_private_regular_file(path: Path, *, require_0600=False):
|
||||
try:
|
||||
mode = path.stat()
|
||||
except OSError as exc:
|
||||
raise ValueError(f"source path is unavailable: {path}") from exc
|
||||
if not stat.S_ISREG(mode.st_mode):
|
||||
raise ValueError("source path must be a regular file")
|
||||
if mode.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
||||
raise ValueError("source path must not be group/world writable")
|
||||
if require_0600 and mode.st_mode & (stat.S_IRGRP | stat.S_IROTH):
|
||||
raise ValueError("file source requires mode 0600 or stricter")
|
||||
177
xai_enroller/coordinator.py
Normal file
177
xai_enroller/coordinator.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
|
||||
import httpx
|
||||
|
||||
from .ledger import Ledger
|
||||
from .models import (
|
||||
AuthorizationStatus,
|
||||
JobResult,
|
||||
JobStatus,
|
||||
)
|
||||
|
||||
|
||||
class EnrollmentCoordinator:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source,
|
||||
protocol,
|
||||
executor,
|
||||
sink,
|
||||
ledger_path,
|
||||
ledger_salt,
|
||||
concurrency=1,
|
||||
timeout=1800,
|
||||
retry_attempts=0,
|
||||
event_callback=None,
|
||||
):
|
||||
self.source = source
|
||||
self.protocol = protocol
|
||||
self.executor = executor
|
||||
self.sink = sink
|
||||
self.ledger = Ledger(ledger_path, ledger_salt)
|
||||
self.concurrency = concurrency
|
||||
self.timeout = timeout
|
||||
self.retry_attempts = max(0, retry_attempts)
|
||||
self.event_callback = event_callback
|
||||
self._semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
@staticmethod
|
||||
def _is_pre_device_transport_failure(error):
|
||||
return isinstance(error, (httpx.TransportError, OSError))
|
||||
|
||||
def _emit(self, kind, data):
|
||||
if self.event_callback is None:
|
||||
return
|
||||
try:
|
||||
self.event_callback(kind, data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def run(self, target=1):
|
||||
return await self.run_records(self.source.records(), target=target)
|
||||
|
||||
async def run_records(self, records, target=100):
|
||||
if not 1 <= target <= 100:
|
||||
raise ValueError("target must be between 1 and 100")
|
||||
self.ledger.recover_pending()
|
||||
results = []
|
||||
tasks = []
|
||||
index = 0
|
||||
if hasattr(records, "__aiter__"):
|
||||
async for source in records:
|
||||
if index >= target:
|
||||
break
|
||||
index += 1
|
||||
tasks.append(asyncio.create_task(self._run_one(source)))
|
||||
else:
|
||||
for source in records:
|
||||
if index >= target:
|
||||
break
|
||||
index += 1
|
||||
tasks.append(asyncio.create_task(self._run_one(source)))
|
||||
if hasattr(self.executor, "start"):
|
||||
await self.executor.start()
|
||||
try:
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks)
|
||||
return results
|
||||
finally:
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if hasattr(self.executor, "close"):
|
||||
with suppress(Exception):
|
||||
await self.executor.close()
|
||||
|
||||
async def _run_one(self, source):
|
||||
async with self._semaphore:
|
||||
for attempt in range(1, self.retry_attempts + 2):
|
||||
job_id = self.ledger.start(source.source_id, attempt=attempt)
|
||||
try:
|
||||
async with asyncio.timeout(self.timeout):
|
||||
try:
|
||||
flow = await self.protocol.start_device_flow()
|
||||
except Exception as error:
|
||||
result = JobResult(
|
||||
source.source_id,
|
||||
JobStatus.TRANSPORT_FAILED,
|
||||
"device_flow_failed",
|
||||
attempt,
|
||||
)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
if (
|
||||
self._is_pre_device_transport_failure(error)
|
||||
and attempt <= self.retry_attempts
|
||||
):
|
||||
continue
|
||||
return result
|
||||
self._emit(
|
||||
"device_flow",
|
||||
{
|
||||
"source_id": source.source_id,
|
||||
"user_code": flow.user_code,
|
||||
"verification_url": flow.verification_url,
|
||||
},
|
||||
)
|
||||
return await self._attempt_after_flow(source, job_id, flow, attempt)
|
||||
except asyncio.TimeoutError:
|
||||
result = JobResult(source.source_id, JobStatus.TIMEOUT, "timeout", attempt)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
result = JobResult(source.source_id, JobStatus.CANCELLED, "cancelled", attempt)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
raise
|
||||
except Exception:
|
||||
result = JobResult(
|
||||
source.source_id, JobStatus.TRANSPORT_FAILED, "transport_failed", attempt
|
||||
)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
|
||||
async def _attempt_after_flow(self, source, job_id, flow, attempt):
|
||||
authorization = await self.executor.confirm(source, flow)
|
||||
if authorization.status is not AuthorizationStatus.AUTHORIZED:
|
||||
status = JobStatus(authorization.status.value)
|
||||
result = JobResult(source.source_id, status, authorization.reason_code, attempt)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
if self.sink is None:
|
||||
result = JobResult(source.source_id, JobStatus.SINK_FAILED, "sink_unconfigured", attempt)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
try:
|
||||
credential = await self.protocol.poll_token(
|
||||
endpoint=flow.token_endpoint,
|
||||
flow=flow,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
reason = str(error)
|
||||
mapping = {
|
||||
"oauth_denied": JobStatus.OAUTH_DENIED,
|
||||
"oauth_expired": JobStatus.OAUTH_EXPIRED,
|
||||
"oauth_rejected": JobStatus.OAUTH_REJECTED,
|
||||
}
|
||||
result = JobResult(
|
||||
source.source_id, mapping.get(reason, JobStatus.TRANSPORT_FAILED), reason, attempt
|
||||
)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
try:
|
||||
receipt = await self.sink.store(credential)
|
||||
except Exception:
|
||||
result = JobResult(source.source_id, JobStatus.SINK_FAILED, "sink_failed", attempt)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code)
|
||||
return result
|
||||
result = JobResult(
|
||||
source.source_id,
|
||||
JobStatus.IMPORTED,
|
||||
"imported",
|
||||
attempt,
|
||||
sink_receipt_fingerprint=receipt.fingerprint,
|
||||
)
|
||||
self.ledger.finish(job_id, result.status, result.reason_code, receipt.fingerprint)
|
||||
return result
|
||||
595
xai_enroller/executors.py
Normal file
595
xai_enroller/executors.py
Normal file
@@ -0,0 +1,595 @@
|
||||
import asyncio
|
||||
import glob
|
||||
import importlib
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from grok_register.clearance import (
|
||||
apply_clearance_to_context,
|
||||
cached_user_agent,
|
||||
playwright_proxy_settings,
|
||||
)
|
||||
|
||||
from .models import AuthorizationResult, AuthorizationStatus, DeviceFlow, SourceRecord
|
||||
|
||||
|
||||
class HTTPProbeExecutor:
|
||||
def __init__(self, client: httpx.AsyncClient, max_body=64 * 1024):
|
||||
self.client = client
|
||||
self.max_body = max_body
|
||||
|
||||
async def confirm(self, source: SourceRecord, flow: DeviceFlow):
|
||||
parsed = urlparse(flow.verification_url)
|
||||
if parsed.scheme != "https" or parsed.hostname != "accounts.x.ai":
|
||||
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "invalid_url")
|
||||
response = await self.client.get(
|
||||
flow.verification_url,
|
||||
headers={"Cookie": f"sso={source.sso_token}"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
body = await response.aread()
|
||||
if len(body) > self.max_body:
|
||||
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "response_too_large")
|
||||
text = body.decode("utf-8", errors="replace").lower()
|
||||
if response.status_code == 403 and any(
|
||||
marker in text for marker in ("challenge", "cf-chl", "captcha")
|
||||
):
|
||||
return AuthorizationResult(AuthorizationStatus.NEEDS_BROWSER, "challenge")
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
return AuthorizationResult(AuthorizationStatus.NEEDS_INTERACTION, "redirect")
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION, "http_probe_inconclusive"
|
||||
)
|
||||
|
||||
|
||||
class PlaywrightExecutor:
|
||||
ALLOWED_CONTROLS = frozenset({"Authorize", "Allow", "Continue", "Confirm"})
|
||||
ATTEMPT_TIMEOUT_SECONDS = 55.0
|
||||
CLOSE_TIMEOUT_SECONDS = 5.0
|
||||
# Keep confirm loops tight: most successful device verifies finish under ~15s.
|
||||
LOOP_DEADLINE_SECONDS = 28.0
|
||||
COOKIE_WAIT_MS = 250
|
||||
CONSENT_WAIT_MS = 350
|
||||
CODE_WAIT_MS = 300
|
||||
CHALLENGE_WAIT_MS = 700
|
||||
POLL_WAIT_MS = 250
|
||||
# Drop heavy assets that never participate in device verification.
|
||||
BLOCKED_RESOURCE_TYPES = frozenset({"image", "media", "font", "stylesheet"})
|
||||
BLOCKED_URL_MARKERS = (
|
||||
"google-analytics",
|
||||
"googletagmanager",
|
||||
"doubleclick",
|
||||
"facebook.net",
|
||||
"hotjar",
|
||||
"segment.io",
|
||||
)
|
||||
|
||||
def __init__(self, concurrency=1, playwright_factory=None, executable_path=None):
|
||||
self.concurrency = concurrency
|
||||
self.playwright_factory = playwright_factory
|
||||
self.executable_path = executable_path or self._find_executable_path()
|
||||
self._playwright = None
|
||||
self._browser = None
|
||||
self._semaphore = asyncio.Semaphore(concurrency)
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _find_executable_path():
|
||||
configured = os.environ.get("XAI_ENROLLER_BROWSER_EXECUTABLE")
|
||||
if configured:
|
||||
return configured
|
||||
candidates = glob.glob(os.path.expanduser("~/.cloakbrowser/chromium-*/chrome"))
|
||||
candidates.extend(
|
||||
glob.glob(
|
||||
os.path.expanduser(
|
||||
"~/.cloakbrowser/chromium-*/Chromium.app/Contents/MacOS/Chromium"
|
||||
)
|
||||
)
|
||||
)
|
||||
return sorted(candidates)[-1] if candidates else None
|
||||
|
||||
async def start(self):
|
||||
async with self._lifecycle_lock:
|
||||
if self._browser is not None:
|
||||
is_connected = getattr(self._browser, "is_connected", None)
|
||||
if not callable(is_connected) or is_connected():
|
||||
return
|
||||
await self._close_unlocked()
|
||||
factory = self.playwright_factory
|
||||
if factory is None:
|
||||
module = importlib.import_module("playwright.async_api")
|
||||
factory = module.async_playwright
|
||||
playwright = await factory().start()
|
||||
options = {"headless": True}
|
||||
if self.executable_path:
|
||||
options["executable_path"] = self.executable_path
|
||||
# REGISTER_PROXY / HTTP_PROXY / HTTPS_PROXY → this process only
|
||||
proxy = playwright_proxy_settings()
|
||||
if proxy:
|
||||
options["proxy"] = proxy
|
||||
try:
|
||||
browser = await playwright.chromium.launch(**options)
|
||||
except BaseException:
|
||||
await self._close_transport_resource(playwright.stop())
|
||||
raise
|
||||
self._playwright = playwright
|
||||
self._browser = browser
|
||||
|
||||
async def close(self):
|
||||
async with self._lifecycle_lock:
|
||||
await self._close_unlocked()
|
||||
|
||||
async def _close_unlocked(self):
|
||||
browser = self._browser
|
||||
playwright = self._playwright
|
||||
self._browser = None
|
||||
self._playwright = None
|
||||
try:
|
||||
if browser is not None:
|
||||
await self._close_transport_resource(browser.close())
|
||||
finally:
|
||||
if playwright is not None:
|
||||
await self._close_transport_resource(playwright.stop())
|
||||
|
||||
@staticmethod
|
||||
def _consume_task_result(task):
|
||||
with suppress(BaseException):
|
||||
task.result()
|
||||
|
||||
@classmethod
|
||||
async def _close_transport_resource(cls, awaitable):
|
||||
task = asyncio.create_task(awaitable)
|
||||
try:
|
||||
done, _pending = await asyncio.wait(
|
||||
{task}, timeout=cls.CLOSE_TIMEOUT_SECONDS
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
task.cancel()
|
||||
task.add_done_callback(cls._consume_task_result)
|
||||
raise
|
||||
if task in done:
|
||||
cls._consume_task_result(task)
|
||||
return True
|
||||
task.cancel()
|
||||
task.add_done_callback(cls._consume_task_result)
|
||||
return False
|
||||
|
||||
async def _recycle_browser(self, browser):
|
||||
async with self._lifecycle_lock:
|
||||
if self._browser is browser:
|
||||
await self._close_unlocked()
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
await self.close()
|
||||
|
||||
@classmethod
|
||||
async def _close_attempt_resources(cls, page, context):
|
||||
async def close_session():
|
||||
closers = []
|
||||
if page is not None:
|
||||
closers.append(page.close())
|
||||
if context is not None:
|
||||
closers.append(context.close())
|
||||
if closers:
|
||||
await asyncio.gather(*closers, return_exceptions=True)
|
||||
|
||||
cleanup = asyncio.create_task(close_session())
|
||||
cancelled = False
|
||||
deadline = asyncio.get_running_loop().time() + cls.CLOSE_TIMEOUT_SECONDS
|
||||
while not cleanup.done():
|
||||
remaining = deadline - asyncio.get_running_loop().time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
await asyncio.wait({cleanup}, timeout=remaining)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
if cleanup.done():
|
||||
cls._consume_task_result(cleanup)
|
||||
else:
|
||||
# asyncio.wait_for() is not a hard deadline: it cancels the child and
|
||||
# then waits for cancellation to finish, which a wedged Playwright
|
||||
# transport may never do. Abandon the cleanup task after one real
|
||||
# wall-clock deadline and consume its eventual result asynchronously.
|
||||
cleanup.cancel()
|
||||
cleanup.add_done_callback(cls._consume_task_result)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
return cleanup.done()
|
||||
|
||||
@staticmethod
|
||||
async def _click_visible_exact(page, names):
|
||||
for name in names:
|
||||
try:
|
||||
button = page.get_by_role("button", name=name, exact=True)
|
||||
except TypeError:
|
||||
button = page.get_by_role("button", name=name)
|
||||
for index in range(await button.count()):
|
||||
candidate = button.nth(index) if hasattr(button, "nth") else button.first
|
||||
is_visible = getattr(candidate, "is_visible", None)
|
||||
if is_visible is None or await is_visible():
|
||||
await candidate.click()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def _click_visible_turnstile(page):
|
||||
"""Click a visible Turnstile widget using the registration solver motion."""
|
||||
for selector in (
|
||||
".cf-turnstile",
|
||||
"iframe[src*='challenges.cloudflare.com']",
|
||||
"iframe[src*='turnstile']",
|
||||
):
|
||||
locator = page.locator(selector).first
|
||||
if not await locator.count() or not await locator.is_visible():
|
||||
continue
|
||||
box = await locator.bounding_box()
|
||||
if not box:
|
||||
continue
|
||||
x = box["x"] + box["width"] / 2
|
||||
y = box["y"] + box["height"] / 2
|
||||
await page.mouse.move(max(0, x - 25), max(0, y - 8))
|
||||
await page.mouse.move(x, y, steps=8)
|
||||
await page.mouse.down()
|
||||
await asyncio.sleep(0.05)
|
||||
await page.mouse.up()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _expanded_cookies(source):
|
||||
allowed = {
|
||||
"name",
|
||||
"value",
|
||||
"url",
|
||||
"domain",
|
||||
"path",
|
||||
"expires",
|
||||
"httpOnly",
|
||||
"secure",
|
||||
"sameSite",
|
||||
}
|
||||
cookies = []
|
||||
for source_cookie in source.cookies:
|
||||
cookie = {
|
||||
key: value for key, value in source_cookie.items() if key in allowed
|
||||
}
|
||||
if cookie.get("domain"):
|
||||
cookie.pop("url", None)
|
||||
elif cookie.get("url"):
|
||||
cookie.pop("domain", None)
|
||||
cookie.pop("path", None)
|
||||
cookies.append(cookie)
|
||||
if not cookies:
|
||||
cookies = [
|
||||
{
|
||||
"name": "sso",
|
||||
"value": source.sso_token,
|
||||
"domain": "accounts.x.ai",
|
||||
"path": "/",
|
||||
"secure": True,
|
||||
"httpOnly": True,
|
||||
}
|
||||
]
|
||||
seen = {
|
||||
(cookie.get("name"), cookie.get("domain"), cookie.get("path", "/"))
|
||||
for cookie in cookies
|
||||
}
|
||||
for cookie in list(cookies) if source.cookies else ():
|
||||
name = cookie.get("name", "")
|
||||
if not name.startswith("sso"):
|
||||
continue
|
||||
key = (name, ".x.ai", cookie.get("path", "/"))
|
||||
if key in seen:
|
||||
continue
|
||||
clone = dict(cookie)
|
||||
clone.pop("url", None)
|
||||
clone["domain"] = ".x.ai"
|
||||
clone.setdefault("path", "/")
|
||||
cookies.append(clone)
|
||||
seen.add(key)
|
||||
return cookies
|
||||
|
||||
async def confirm(self, source: SourceRecord, flow: DeviceFlow):
|
||||
await self.start()
|
||||
async with self._semaphore:
|
||||
browser = self._browser
|
||||
attempt = asyncio.create_task(
|
||||
self._confirm_once(browser, source, flow)
|
||||
)
|
||||
try:
|
||||
done, _pending = await asyncio.wait(
|
||||
{attempt}, timeout=self.ATTEMPT_TIMEOUT_SECONDS
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
attempt.cancel()
|
||||
attempt.add_done_callback(self._consume_task_result)
|
||||
raise
|
||||
if attempt in done:
|
||||
return attempt.result()
|
||||
|
||||
# A Playwright transport can swallow cancellation while one of its
|
||||
# RPCs is wedged. Do not let asyncio.wait_for() extend the nominal
|
||||
# timeout indefinitely: detach the attempt and replace the complete
|
||||
# browser transport before admitting the next account.
|
||||
attempt.cancel()
|
||||
attempt.add_done_callback(self._consume_task_result)
|
||||
await self._recycle_browser(browser)
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION, "confirmation_timeout"
|
||||
)
|
||||
|
||||
async def _install_request_filters(self, page):
|
||||
"""Skip non-essential resources so device verification spends less wall time."""
|
||||
|
||||
async def route_handler(route):
|
||||
request = route.request
|
||||
resource_type = getattr(request, "resource_type", "") or ""
|
||||
url = getattr(request, "url", "") or ""
|
||||
if resource_type in self.BLOCKED_RESOURCE_TYPES or any(
|
||||
marker in url for marker in self.BLOCKED_URL_MARKERS
|
||||
):
|
||||
await route.abort()
|
||||
return
|
||||
await route.continue_()
|
||||
|
||||
with suppress(Exception):
|
||||
await page.route("**/*", route_handler)
|
||||
|
||||
async def _confirm_once(self, browser, source: SourceRecord, flow: DeviceFlow):
|
||||
context = None
|
||||
page = None
|
||||
code_submitted = False
|
||||
consent_submitted = False
|
||||
challenge_clicks = 0
|
||||
try:
|
||||
context_kwargs = {}
|
||||
ua = cached_user_agent()
|
||||
if ua:
|
||||
context_kwargs["user_agent"] = ua
|
||||
context = await browser.new_context(**context_kwargs)
|
||||
# CF cookies from external FlareSolverr prewarm (host-level)
|
||||
await apply_clearance_to_context(context)
|
||||
page = await context.new_page()
|
||||
await self._install_request_filters(page)
|
||||
await context.add_cookies(self._expanded_cookies(source))
|
||||
await page.goto(flow.verification_url, wait_until="domcontentloaded")
|
||||
deadline = (
|
||||
asyncio.get_running_loop().time() + self.LOOP_DEADLINE_SECONDS
|
||||
)
|
||||
unknown_since = None
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
parsed = urlparse(page.url)
|
||||
if parsed.scheme != "https" or not parsed.hostname or not (
|
||||
parsed.hostname == "x.ai"
|
||||
or parsed.hostname.endswith(".x.ai")
|
||||
):
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION, "unsafe_page"
|
||||
)
|
||||
query_error = parse_qs(parsed.query).get("error", [None])[0]
|
||||
text = await page.locator("body").inner_text()
|
||||
text_lower = text.lower()
|
||||
page_title = getattr(page, "title", None)
|
||||
title_lower = (
|
||||
(await page_title()).lower() if page_title is not None else ""
|
||||
)
|
||||
|
||||
if query_error == "rate_limited":
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION, "rate_limited"
|
||||
)
|
||||
if query_error:
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION, "device_verify_failed"
|
||||
)
|
||||
if any(
|
||||
marker in text_lower
|
||||
for marker in ("rate limit", "too many requests", "请求过于频繁")
|
||||
):
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION, "rate_limited"
|
||||
)
|
||||
if (
|
||||
"attention required" in title_lower
|
||||
and "cloudflare" in title_lower
|
||||
) or any(
|
||||
marker in text_lower
|
||||
for marker in (
|
||||
"sorry, you have been blocked",
|
||||
"unable to access x.ai",
|
||||
"cloudflare ray id",
|
||||
)
|
||||
):
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION,
|
||||
"challenge_required",
|
||||
)
|
||||
if "/oauth2/device/done" in parsed.path or any(
|
||||
marker in text_lower
|
||||
for marker in ("device authorized", "设备已授权")
|
||||
):
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.AUTHORIZED, "confirmed"
|
||||
)
|
||||
|
||||
cookie_clicked = await self._click_visible_exact(
|
||||
page,
|
||||
(
|
||||
"全部拒绝",
|
||||
"拒绝全部",
|
||||
"Reject all",
|
||||
"Reject All",
|
||||
),
|
||||
)
|
||||
if cookie_clicked:
|
||||
unknown_since = None
|
||||
await page.wait_for_timeout(self.COOKIE_WAIT_MS)
|
||||
continue
|
||||
|
||||
if "/oauth2/device/consent" in parsed.path or any(
|
||||
marker in text_lower
|
||||
for marker in ("authorize grok build", "授权 grok build")
|
||||
):
|
||||
if not consent_submitted:
|
||||
allowed = await self._click_visible_exact(
|
||||
page, ("允许", "Allow", "Authorize", "Approve")
|
||||
)
|
||||
if allowed:
|
||||
consent_submitted = True
|
||||
unknown_since = None
|
||||
await page.wait_for_timeout(self.CONSENT_WAIT_MS)
|
||||
continue
|
||||
else:
|
||||
await page.wait_for_timeout(self.POLL_WAIT_MS)
|
||||
continue
|
||||
|
||||
code_input = page.locator('input[name="user_code"]')
|
||||
try:
|
||||
code_input_count = await code_input.count()
|
||||
except AttributeError:
|
||||
if await self._click_visible_exact(
|
||||
page, self.ALLOWED_CONTROLS
|
||||
):
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.AUTHORIZED,
|
||||
"confirmed",
|
||||
)
|
||||
code_input_count = 0
|
||||
if code_input_count and await code_input.first.is_visible():
|
||||
if code_submitted:
|
||||
await page.wait_for_timeout(self.POLL_WAIT_MS)
|
||||
continue
|
||||
unknown_since = None
|
||||
current = await code_input.first.input_value()
|
||||
# fill() is far cheaper than press_sequentially and the
|
||||
# device-code form does not require keystroke events.
|
||||
if flow.user_code.replace("-", "") not in current.replace(
|
||||
"-", ""
|
||||
):
|
||||
await code_input.first.fill(flow.user_code)
|
||||
submit = page.locator(
|
||||
'button[type="submit"], input[type="submit"]'
|
||||
)
|
||||
if not await submit.count():
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION,
|
||||
"submit_missing",
|
||||
)
|
||||
predicate = lambda response: (
|
||||
urlparse(response.url).hostname == "auth.x.ai"
|
||||
and urlparse(response.url).path == "/oauth2/device/verify"
|
||||
)
|
||||
async with page.expect_response(
|
||||
predicate, timeout=12000
|
||||
) as response_info:
|
||||
code_submitted = True
|
||||
await submit.first.click()
|
||||
response = await response_info.value
|
||||
location = urlparse(response.headers.get("location", ""))
|
||||
error = parse_qs(location.query).get("error", [None])[0]
|
||||
if error == "rate_limited":
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION,
|
||||
"rate_limited",
|
||||
)
|
||||
if error:
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION,
|
||||
"device_verify_failed",
|
||||
)
|
||||
await page.wait_for_timeout(self.CODE_WAIT_MS)
|
||||
continue
|
||||
|
||||
if any(
|
||||
marker in text_lower
|
||||
for marker in (
|
||||
"continue with email",
|
||||
"sign in with email",
|
||||
"使用邮箱登录",
|
||||
)
|
||||
) or await page.locator('input[type="password"]').count():
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION,
|
||||
"login_required",
|
||||
)
|
||||
|
||||
if any(
|
||||
marker in text_lower
|
||||
for marker in (
|
||||
"captcha",
|
||||
"verify you are human",
|
||||
"security challenge",
|
||||
"turnstile",
|
||||
"人机验证",
|
||||
"安全验证",
|
||||
)
|
||||
):
|
||||
if (
|
||||
challenge_clicks < 3
|
||||
and await self._click_visible_turnstile(page)
|
||||
):
|
||||
challenge_clicks += 1
|
||||
unknown_since = None
|
||||
await page.wait_for_timeout(self.CHALLENGE_WAIT_MS)
|
||||
continue
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION,
|
||||
"challenge_required",
|
||||
)
|
||||
|
||||
if any(
|
||||
marker in text_lower
|
||||
for marker in (
|
||||
"multi-factor",
|
||||
"two-factor",
|
||||
"authentication code",
|
||||
"verification code",
|
||||
"双重验证",
|
||||
"验证码",
|
||||
)
|
||||
):
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION,
|
||||
"mfa_required",
|
||||
)
|
||||
|
||||
if await self._click_visible_exact(page, self.ALLOWED_CONTROLS):
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.AUTHORIZED,
|
||||
"confirmed",
|
||||
)
|
||||
|
||||
now = asyncio.get_running_loop().time()
|
||||
if unknown_since is None:
|
||||
unknown_since = now
|
||||
elif now - unknown_since >= 2.5:
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION, "unknown_page"
|
||||
)
|
||||
|
||||
await page.wait_for_timeout(self.POLL_WAIT_MS)
|
||||
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION, "confirmation_timeout"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
is_timeout = (
|
||||
error.__class__.__name__ == "TimeoutError"
|
||||
and error.__class__.__module__.startswith("playwright")
|
||||
)
|
||||
return AuthorizationResult(
|
||||
AuthorizationStatus.NEEDS_INTERACTION,
|
||||
"confirmation_timeout" if is_timeout else "browser_error",
|
||||
)
|
||||
finally:
|
||||
await self._close_attempt_resources(page, context)
|
||||
105
xai_enroller/inventory.py
Normal file
105
xai_enroller/inventory.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class InventoryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimBatch:
|
||||
batch_id: str
|
||||
directory: Path
|
||||
moved: int
|
||||
note: str = ""
|
||||
|
||||
|
||||
class CredentialInventory:
|
||||
def __init__(self, ledger, available_directory, claimed_directory):
|
||||
self.ledger = ledger
|
||||
self.available_directory = Path(available_directory)
|
||||
self.claimed_directory = Path(claimed_directory)
|
||||
|
||||
def take(self, limit, note=""):
|
||||
try:
|
||||
limit = int(limit)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("claim count must be an integer") from exc
|
||||
if limit <= 0:
|
||||
raise ValueError("claim count must be positive")
|
||||
note = str(note or "").strip()
|
||||
batch_id = self._new_batch_id()
|
||||
receipts = self.ledger.claim_available(limit, batch_id)
|
||||
return self._complete_batch(batch_id, receipts, note)
|
||||
|
||||
def recover(self):
|
||||
rows = self.ledger.pending_claims()
|
||||
batches = {}
|
||||
for row in rows:
|
||||
batches.setdefault(row["batch_id"], []).append(
|
||||
row["sink_receipt_fingerprint"]
|
||||
)
|
||||
recovered = 0
|
||||
for batch_id, receipts in batches.items():
|
||||
recovered += self._complete_batch(batch_id, receipts, "").moved
|
||||
return recovered
|
||||
|
||||
@staticmethod
|
||||
def _new_batch_id():
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
return f"{timestamp}-{secrets.token_hex(3)}"
|
||||
|
||||
@staticmethod
|
||||
def _validate_receipt(receipt):
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", receipt):
|
||||
raise InventoryError("invalid credential receipt")
|
||||
|
||||
@staticmethod
|
||||
def _fsync_directory(directory):
|
||||
descriptor = os.open(directory, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
def _complete_batch(self, batch_id, receipts, note):
|
||||
destination_directory = self.claimed_directory / batch_id
|
||||
if not receipts:
|
||||
return ClaimBatch(batch_id, destination_directory, 0, note)
|
||||
self.available_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
self.claimed_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
destination_directory.mkdir(mode=0o700, parents=False, exist_ok=True)
|
||||
for directory in (
|
||||
self.available_directory,
|
||||
self.claimed_directory,
|
||||
destination_directory,
|
||||
):
|
||||
os.chmod(directory, 0o700)
|
||||
|
||||
moved = 0
|
||||
for receipt in receipts:
|
||||
self._validate_receipt(receipt)
|
||||
source = self.available_directory / f"{receipt}.json"
|
||||
destination = destination_directory / source.name
|
||||
source_exists = source.is_file()
|
||||
destination_exists = destination.is_file()
|
||||
if source_exists and destination_exists:
|
||||
raise InventoryError("credential exists in both inventory locations")
|
||||
if not source_exists and not destination_exists:
|
||||
raise InventoryError("credential file is missing")
|
||||
if source_exists:
|
||||
os.replace(source, destination)
|
||||
os.chmod(destination, 0o600)
|
||||
moved += 1
|
||||
|
||||
self._fsync_directory(self.available_directory)
|
||||
self._fsync_directory(destination_directory)
|
||||
self._fsync_directory(self.claimed_directory)
|
||||
marked = self.ledger.mark_claimed(batch_id, note=note)
|
||||
if marked != len(receipts):
|
||||
raise InventoryError("inventory batch did not commit completely")
|
||||
return ClaimBatch(batch_id, destination_directory, moved, note)
|
||||
275
xai_enroller/ledger.py
Normal file
275
xai_enroller/ledger.py
Normal file
@@ -0,0 +1,275 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .models import JobStatus
|
||||
|
||||
|
||||
class Ledger:
|
||||
def __init__(self, path: Path, salt: bytes):
|
||||
self.path = Path(path)
|
||||
self.salt = bytes(salt)
|
||||
self._init()
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
def _init(self):
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
job_id INTEGER PRIMARY KEY,
|
||||
source_fingerprint TEXT NOT NULL,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
reason_code TEXT,
|
||||
sink_receipt_fingerprint TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
row["name"] for row in connection.execute("PRAGMA table_info(jobs)")
|
||||
}
|
||||
if "authorization_started" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE jobs ADD COLUMN authorization_started INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS jobs_source_status_idx "
|
||||
"ON jobs(source_fingerprint, status)"
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS credential_inventory (
|
||||
sink_receipt_fingerprint TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL CHECK(state IN ('available', 'claiming', 'claimed')),
|
||||
claimed_at TEXT NOT NULL DEFAULT '',
|
||||
batch_id TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS credential_inventory_state_idx "
|
||||
"ON credential_inventory(state)"
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO credential_inventory(
|
||||
sink_receipt_fingerprint, state
|
||||
)
|
||||
SELECT sink_receipt_fingerprint, 'available'
|
||||
FROM jobs
|
||||
WHERE status=?
|
||||
AND COALESCE(TRIM(sink_receipt_fingerprint), '') <> ''
|
||||
""",
|
||||
(JobStatus.IMPORTED.value,),
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def fingerprint(self, source_id):
|
||||
return hmac.new(self.salt, source_id.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
def _fingerprint(self, source_id):
|
||||
return self.fingerprint(source_id)
|
||||
|
||||
def start(self, source_id, attempt=1):
|
||||
return self.start_fingerprint(self.fingerprint(source_id), attempt=attempt)
|
||||
|
||||
def start_fingerprint(self, source_fingerprint, attempt=None):
|
||||
if attempt is None:
|
||||
attempt = self.next_attempt(source_fingerprint)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO jobs(source_fingerprint, attempt_number, status, started_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(source_fingerprint, attempt, "pending", now),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def next_attempt(self, source_fingerprint):
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt "
|
||||
"FROM jobs WHERE source_fingerprint=?",
|
||||
(source_fingerprint,),
|
||||
).fetchone()
|
||||
return int(row["next_attempt"])
|
||||
|
||||
def mark_authorization_started(self, job_id):
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE jobs SET authorization_started=1 "
|
||||
"WHERE job_id=? AND status='pending'",
|
||||
(job_id,),
|
||||
)
|
||||
|
||||
def finish(self, job_id, status, reason_code, sink_receipt_fingerprint=None):
|
||||
status_value = status.value if isinstance(status, JobStatus) else str(status)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE jobs SET status=?, finished_at=?, reason_code=?, "
|
||||
"sink_receipt_fingerprint=? "
|
||||
"WHERE job_id=? AND status='pending'",
|
||||
(status_value, now, reason_code, sink_receipt_fingerprint, job_id),
|
||||
)
|
||||
if (
|
||||
cursor.rowcount == 1
|
||||
and status_value == JobStatus.IMPORTED.value
|
||||
and sink_receipt_fingerprint
|
||||
):
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO credential_inventory("
|
||||
"sink_receipt_fingerprint, state) VALUES (?, 'available')",
|
||||
(sink_receipt_fingerprint,),
|
||||
)
|
||||
|
||||
def claim_available(self, limit, batch_id):
|
||||
limit = int(limit)
|
||||
if limit <= 0:
|
||||
return []
|
||||
batch_id = str(batch_id).strip()
|
||||
if not batch_id:
|
||||
raise ValueError("batch_id must not be empty")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"""
|
||||
WITH latest_import AS (
|
||||
SELECT sink_receipt_fingerprint, MAX(finished_at) AS finished_at
|
||||
FROM jobs
|
||||
WHERE status=?
|
||||
AND COALESCE(TRIM(sink_receipt_fingerprint), '') <> ''
|
||||
GROUP BY sink_receipt_fingerprint
|
||||
)
|
||||
SELECT inventory.sink_receipt_fingerprint
|
||||
FROM credential_inventory AS inventory
|
||||
JOIN latest_import USING (sink_receipt_fingerprint)
|
||||
WHERE inventory.state='available'
|
||||
ORDER BY latest_import.finished_at DESC,
|
||||
inventory.sink_receipt_fingerprint ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(JobStatus.IMPORTED.value, limit),
|
||||
).fetchall()
|
||||
fingerprints = [row["sink_receipt_fingerprint"] for row in rows]
|
||||
connection.executemany(
|
||||
"UPDATE credential_inventory SET state='claiming', batch_id=?, "
|
||||
"claimed_at='', note='' "
|
||||
"WHERE sink_receipt_fingerprint=? AND state='available'",
|
||||
((batch_id, fingerprint) for fingerprint in fingerprints),
|
||||
)
|
||||
return fingerprints
|
||||
|
||||
def mark_claimed(self, batch_id, note=""):
|
||||
batch_id = str(batch_id).strip()
|
||||
if not batch_id:
|
||||
raise ValueError("batch_id must not be empty")
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE credential_inventory SET state='claimed', claimed_at=?, note=? "
|
||||
"WHERE state='claiming' AND batch_id=?",
|
||||
(datetime.now(timezone.utc).isoformat(), str(note), batch_id),
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
def inventory_counts(self):
|
||||
counts = {"available": 0, "claiming": 0, "claimed": 0}
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT state, COUNT(*) AS count FROM credential_inventory GROUP BY state"
|
||||
)
|
||||
for row in rows:
|
||||
counts[row["state"]] = int(row["count"])
|
||||
return counts
|
||||
|
||||
def pending_claims(self, batch_id=None):
|
||||
query = (
|
||||
"SELECT sink_receipt_fingerprint, batch_id, note "
|
||||
"FROM credential_inventory WHERE state='claiming'"
|
||||
)
|
||||
params = ()
|
||||
if batch_id is not None:
|
||||
query += " AND batch_id=?"
|
||||
params = (str(batch_id),)
|
||||
query += " ORDER BY batch_id, sink_receipt_fingerprint"
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(query, params)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def recover_claiming(self, batch_id=None, *, note=""):
|
||||
query = (
|
||||
"UPDATE credential_inventory SET state='available', claimed_at='', "
|
||||
"batch_id='', note=? WHERE state='claiming'"
|
||||
)
|
||||
params = [str(note)]
|
||||
if batch_id is not None:
|
||||
query += " AND batch_id=?"
|
||||
params.append(str(batch_id))
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(query, params)
|
||||
return cursor.rowcount
|
||||
|
||||
def recover_pending(self, *, reason="recovered_pending"):
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE jobs SET status=?, finished_at=?, reason_code=? WHERE status='pending'",
|
||||
(JobStatus.CANCELLED.value, datetime.now(timezone.utc).isoformat(), reason),
|
||||
)
|
||||
|
||||
def has_imported(self, source_id):
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT 1 FROM jobs WHERE source_fingerprint=? AND status=? LIMIT 1",
|
||||
(self.fingerprint(source_id), JobStatus.IMPORTED.value),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def imported_fingerprints(self):
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT DISTINCT source_fingerprint FROM jobs WHERE status=?",
|
||||
(JobStatus.IMPORTED.value,),
|
||||
)
|
||||
return {row["source_fingerprint"] for row in rows}
|
||||
|
||||
def aggregate_counts(self):
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE WHEN authorization_started=1 OR status=?
|
||||
THEN source_fingerprint END) AS attempted_unique,
|
||||
COUNT(DISTINCT CASE WHEN status=?
|
||||
THEN source_fingerprint END) AS imported_unique,
|
||||
COUNT(CASE WHEN finished_at IS NOT NULL THEN 1 END) AS finalized_attempts,
|
||||
COUNT(CASE WHEN status=? THEN 1 END) AS imported_attempts,
|
||||
COUNT(CASE WHEN reason_code='rate_limited' THEN 1 END) AS rate_limited
|
||||
FROM jobs
|
||||
""",
|
||||
(
|
||||
JobStatus.IMPORTED.value,
|
||||
JobStatus.IMPORTED.value,
|
||||
JobStatus.IMPORTED.value,
|
||||
),
|
||||
).fetchone()
|
||||
return {key: int(row[key] or 0) for key in row.keys()}
|
||||
|
||||
def get(self, job_id):
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT source_fingerprint, attempt_number, status, started_at, finished_at, "
|
||||
"reason_code, sink_receipt_fingerprint FROM jobs WHERE job_id=?",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
128
xai_enroller/models.py
Normal file
128
xai_enroller/models.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceRecord:
|
||||
source_id: str
|
||||
sso_token: str
|
||||
cookies: tuple[dict, ...] = ()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "SourceRecord(<redacted>)"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceFlow:
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_url: str
|
||||
expires_in: int
|
||||
interval: float
|
||||
token_endpoint: str = "https://auth.x.ai/oauth2/token"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "DeviceFlow(<redacted>)"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OAuthCredential:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
id_token: Optional[str]
|
||||
token_type: Optional[str]
|
||||
expires_in: int
|
||||
expires_at: str
|
||||
last_refresh: str
|
||||
subject: Optional[str]
|
||||
token_endpoint: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "OAuthCredential(<redacted>)"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SinkReceipt:
|
||||
fingerprint: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "SinkReceipt(<redacted>)"
|
||||
|
||||
|
||||
class AuthorizationStatus(str, Enum):
|
||||
AUTHORIZED = "authorized"
|
||||
NEEDS_BROWSER = "needs_browser"
|
||||
NEEDS_INTERACTION = "needs_interaction"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationResult:
|
||||
status: AuthorizationStatus
|
||||
reason_code: str
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
IMPORTED = "imported"
|
||||
NEEDS_BROWSER = "needs_browser"
|
||||
NEEDS_INTERACTION = "needs_interaction"
|
||||
SOURCE_INVALID = "source_invalid"
|
||||
OAUTH_DENIED = "oauth_denied"
|
||||
OAUTH_EXPIRED = "oauth_expired"
|
||||
OAUTH_REJECTED = "oauth_rejected"
|
||||
SINK_FAILED = "sink_failed"
|
||||
TRANSPORT_FAILED = "transport_failed"
|
||||
TIMEOUT = "timeout"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobResult:
|
||||
source_id: str
|
||||
status: JobStatus
|
||||
reason_code: str
|
||||
attempt_number: int = 1
|
||||
sink_receipt_fingerprint: Optional[str] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"JobResult(source_id=<redacted>, status={self.status.value!r}, "
|
||||
f"reason_code={self.reason_code!r}, attempt_number={self.attempt_number})"
|
||||
)
|
||||
|
||||
|
||||
class PipelineState(str, Enum):
|
||||
QUEUED = "queued"
|
||||
PREPARED = "prepared"
|
||||
ACTIVE = "active"
|
||||
RETRY_WAITING = "retry_waiting"
|
||||
IMPORTED = "imported"
|
||||
TERMINAL = "terminal"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedJob:
|
||||
source: SourceRecord
|
||||
source_fingerprint: str
|
||||
flow: DeviceFlow
|
||||
flow_created_monotonic: float
|
||||
job_id: int
|
||||
attempt_number: int
|
||||
task_number: Optional[int] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
"PreparedJob(source=<redacted>, flow=<redacted>, "
|
||||
f"attempt_number={self.attempt_number})"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionJob:
|
||||
prepared: PreparedJob
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
"CompletionJob(source=<redacted>, flow=<redacted>, "
|
||||
f"attempt_number={self.prepared.attempt_number})"
|
||||
)
|
||||
181
xai_enroller/protocol.py
Normal file
181
xai_enroller/protocol.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import DeviceFlow, OAuthCredential
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class XAIProfile:
|
||||
client_id: str
|
||||
scope: str
|
||||
version: str = "xai-device-v1"
|
||||
|
||||
@classmethod
|
||||
def default(cls):
|
||||
return cls(
|
||||
client_id="b1a00492-073a-47ea-816f-4c329264a828",
|
||||
scope="openid profile email offline_access grok-cli:access api:access",
|
||||
version="grok-cli-device-v1",
|
||||
)
|
||||
|
||||
|
||||
class XAIProtocol:
|
||||
DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration"
|
||||
MAX_BODY = 64 * 1024
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
profile: XAIProfile,
|
||||
sleep=None,
|
||||
default_poll_interval=5.0,
|
||||
):
|
||||
self.client = client
|
||||
self.profile = profile
|
||||
self.sleep = sleep or asyncio.sleep
|
||||
self.default_poll_interval = float(default_poll_interval)
|
||||
self._discovered = None
|
||||
|
||||
@staticmethod
|
||||
def _allowed_url(value):
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(value)
|
||||
hostname = parsed.hostname
|
||||
return bool(hostname) and parsed.scheme == "https" and (
|
||||
hostname == "x.ai" or hostname.endswith(".x.ai")
|
||||
)
|
||||
|
||||
async def _json(self, response):
|
||||
body = await response.aread()
|
||||
if len(body) > self.MAX_BODY:
|
||||
raise RuntimeError("response body exceeds limit")
|
||||
try:
|
||||
return json.loads(body)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("invalid JSON response") from exc
|
||||
|
||||
async def discover(self):
|
||||
response = await self.client.get(self.DISCOVERY_URL, follow_redirects=False)
|
||||
if response.status_code // 100 != 2:
|
||||
raise RuntimeError("discovery rejected")
|
||||
document = await self._json(response)
|
||||
device_endpoint = document.get("device_authorization_endpoint")
|
||||
token_endpoint = document.get("token_endpoint")
|
||||
if not device_endpoint or not token_endpoint or not all(
|
||||
self._allowed_url(url) for url in (device_endpoint, token_endpoint)
|
||||
):
|
||||
raise ValueError("discovery endpoint failed x.ai HTTPS allowlist")
|
||||
self._discovered = (device_endpoint, token_endpoint)
|
||||
return self._discovered
|
||||
|
||||
async def start_device_flow(self):
|
||||
device_endpoint, token_endpoint = await self.discover()
|
||||
response = await self.client.post(
|
||||
device_endpoint,
|
||||
data={"client_id": self.profile.client_id, "scope": self.profile.scope},
|
||||
follow_redirects=False,
|
||||
)
|
||||
if response.status_code // 100 != 2:
|
||||
raise RuntimeError("device authorization rejected")
|
||||
document = await self._json(response)
|
||||
try:
|
||||
device_code = document["device_code"]
|
||||
user_code = document["user_code"]
|
||||
base_url = document.get("verification_uri") or document["verification_url"]
|
||||
expires_in = int(document["expires_in"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise RuntimeError("invalid device authorization response") from exc
|
||||
if not self._allowed_url(base_url):
|
||||
raise ValueError("verification URL failed x.ai HTTPS allowlist")
|
||||
interval = float(document.get("interval", self.default_poll_interval))
|
||||
verification_url = document.get("verification_uri_complete")
|
||||
if verification_url is None:
|
||||
verification_url = (
|
||||
f"{base_url}{'&' if '?' in base_url else '?'}"
|
||||
f"{urlencode({'user_code': user_code})}"
|
||||
)
|
||||
if not self._allowed_url(verification_url):
|
||||
raise ValueError("verification URL failed x.ai HTTPS allowlist")
|
||||
return DeviceFlow(device_code, user_code, verification_url, expires_in, interval, token_endpoint)
|
||||
|
||||
async def poll_token(self, *, endpoint, flow, timeout):
|
||||
deadline = time.monotonic() + timeout
|
||||
interval = max(0.0, float(flow.interval))
|
||||
while time.monotonic() < deadline:
|
||||
response = await self.client.post(
|
||||
endpoint,
|
||||
data={
|
||||
"client_id": self.profile.client_id,
|
||||
"device_code": flow.device_code,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
document = await self._json(response)
|
||||
if response.status_code // 100 == 2:
|
||||
return self._credential(document, endpoint)
|
||||
error = document.get("error")
|
||||
if error in {"authorization_pending", "slow_down"}:
|
||||
interval += 1 if error == "slow_down" else 0
|
||||
await self.sleep(interval)
|
||||
continue
|
||||
if error == "access_denied":
|
||||
raise RuntimeError("oauth_denied")
|
||||
if error == "expired_token":
|
||||
raise RuntimeError("oauth_expired")
|
||||
raise RuntimeError("oauth_rejected")
|
||||
raise RuntimeError("oauth_expired")
|
||||
|
||||
@staticmethod
|
||||
def _jwt_subject(token):
|
||||
if not isinstance(token, str):
|
||||
return None
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3 or not parts[1]:
|
||||
return None
|
||||
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
try:
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(claims, dict):
|
||||
return None
|
||||
for claim in ("sub", "principal_id"):
|
||||
value = claims.get(claim)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _credential(document, endpoint):
|
||||
if not document.get("access_token") or not document.get("refresh_token"):
|
||||
raise RuntimeError("oauth_rejected")
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_in = int(document.get("expires_in", 3600))
|
||||
expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
subject = (
|
||||
XAIProtocol._jwt_subject(document.get("id_token"))
|
||||
or XAIProtocol._jwt_subject(document["access_token"])
|
||||
or document.get("sub")
|
||||
)
|
||||
return OAuthCredential(
|
||||
access_token=document["access_token"],
|
||||
refresh_token=document["refresh_token"],
|
||||
id_token=document.get("id_token"),
|
||||
token_type=document.get("token_type"),
|
||||
expires_in=expires_in,
|
||||
expires_at=expires_at,
|
||||
last_refresh=now.isoformat().replace("+00:00", "Z"),
|
||||
subject=subject,
|
||||
token_endpoint=endpoint,
|
||||
)
|
||||
630
xai_enroller/remote_stream.py
Normal file
630
xai_enroller/remote_stream.py
Normal file
@@ -0,0 +1,630 @@
|
||||
"""Atomic local snapshots of password-free registration sessions."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
import tempfile
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from .models import SourceRecord
|
||||
|
||||
MAX_SESSION_RECORD_BYTES = 256 * 1024
|
||||
|
||||
|
||||
class RemoteStreamError(RuntimeError):
|
||||
"""A classified stream failure whose text never includes remote output."""
|
||||
|
||||
|
||||
class SSHSnapshotSynchronizer:
|
||||
"""Atomically refresh one validated, password-free local JSONL snapshot."""
|
||||
|
||||
MAX_STDERR_BYTES = 16 * 1024
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host,
|
||||
destination,
|
||||
*,
|
||||
remote_root="/opt/grok-free-register",
|
||||
identity_file=None,
|
||||
process_factory=asyncio.create_subprocess_exec,
|
||||
fingerprint=None,
|
||||
):
|
||||
self.host = host
|
||||
self.destination = Path(destination)
|
||||
self.remote_root = remote_root
|
||||
self.identity_file = identity_file
|
||||
self.process_factory = process_factory
|
||||
self.fingerprint = fingerprint or (lambda source_id: source_id)
|
||||
self.snapshot_fingerprints = None
|
||||
self._process = None
|
||||
|
||||
def _input_generation(self):
|
||||
return None
|
||||
|
||||
def _input_generation_unchanged(self, generation):
|
||||
return True
|
||||
|
||||
def _empty_snapshot_allowed(self):
|
||||
return False
|
||||
|
||||
def _command(self):
|
||||
return (
|
||||
f"cd {shlex.quote(self.remote_root)} && "
|
||||
"python3 scripts/export_registered_sessions.py "
|
||||
"keys/auth-sessions.jsonl keys/accounts.txt"
|
||||
)
|
||||
|
||||
def _args(self):
|
||||
args = [
|
||||
"ssh",
|
||||
"-C",
|
||||
"-T",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=15",
|
||||
"-o",
|
||||
"ServerAliveInterval=15",
|
||||
"-o",
|
||||
"ServerAliveCountMax=3",
|
||||
]
|
||||
if self.identity_file:
|
||||
args.extend(["-i", self.identity_file])
|
||||
args.extend(["--", self.host, self._command()])
|
||||
return args
|
||||
|
||||
async def _read_stderr(self, stream):
|
||||
retained = 0
|
||||
while True:
|
||||
chunk = await stream.read(4096)
|
||||
if not chunk:
|
||||
return
|
||||
retained = min(self.MAX_STDERR_BYTES, retained + len(chunk))
|
||||
|
||||
async def _terminate(self, process):
|
||||
if process is None or process.returncode is not None:
|
||||
return
|
||||
with suppress(ProcessLookupError):
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=3)
|
||||
except TimeoutError:
|
||||
with suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
async def close(self):
|
||||
await self._terminate(self._process)
|
||||
|
||||
async def sync_once(self):
|
||||
self.destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(self.destination.parent, 0o700)
|
||||
fd, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{self.destination.name}.",
|
||||
suffix=".tmp",
|
||||
dir=self.destination.parent,
|
||||
)
|
||||
process = None
|
||||
stderr_task = None
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
input_generation = self._input_generation()
|
||||
process = await self.process_factory(
|
||||
*self._args(),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
limit=MAX_SESSION_RECORD_BYTES + 1,
|
||||
)
|
||||
self._process = process
|
||||
stderr_task = asyncio.create_task(self._read_stderr(process.stderr))
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
fd = -1
|
||||
record_count = 0
|
||||
snapshot_fingerprints = set()
|
||||
while True:
|
||||
try:
|
||||
raw = await process.stdout.readline()
|
||||
except (ValueError, asyncio.LimitOverrunError) as exc:
|
||||
raise ValueError("invalid remote session snapshot") from exc
|
||||
if not raw:
|
||||
break
|
||||
if (
|
||||
not raw.endswith(b"\n")
|
||||
or len(raw) - 1 > MAX_SESSION_RECORD_BYTES
|
||||
):
|
||||
raise ValueError("invalid remote session snapshot")
|
||||
record = parse_session_document(raw[:-1])
|
||||
snapshot_fingerprints.add(self.fingerprint(record.source_id))
|
||||
stream.write(raw)
|
||||
record_count += 1
|
||||
returncode = await process.wait()
|
||||
if stderr_task is not None:
|
||||
await stderr_task
|
||||
stderr_task = None
|
||||
if returncode != 0:
|
||||
raise RemoteStreamError("remote snapshot export failed")
|
||||
if record_count == 0 and not self._empty_snapshot_allowed():
|
||||
raise RemoteStreamError("remote snapshot export was empty")
|
||||
if not self._input_generation_unchanged(input_generation):
|
||||
raise RemoteStreamError("snapshot inputs changed during export")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary_name, self.destination)
|
||||
os.chmod(self.destination, 0o600)
|
||||
directory_fd = os.open(self.destination.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
self.snapshot_fingerprints = frozenset(snapshot_fingerprints)
|
||||
return True
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
if fd >= 0:
|
||||
os.close(fd)
|
||||
if stderr_task is not None:
|
||||
if not stderr_task.done():
|
||||
stderr_task.cancel()
|
||||
await asyncio.gather(stderr_task, return_exceptions=True)
|
||||
with suppress(Exception):
|
||||
await self._terminate(process)
|
||||
if self._process is process:
|
||||
self._process = None
|
||||
with suppress(FileNotFoundError):
|
||||
os.unlink(temporary_name)
|
||||
|
||||
|
||||
class LocalSnapshotSynchronizer(SSHSnapshotSynchronizer):
|
||||
"""Export registration data from one local project into an atomic snapshot."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
register_root,
|
||||
destination,
|
||||
*,
|
||||
process_factory=asyncio.create_subprocess_exec,
|
||||
fingerprint=None,
|
||||
python_executable=sys.executable,
|
||||
exporter_path=None,
|
||||
):
|
||||
super().__init__(
|
||||
"",
|
||||
destination,
|
||||
process_factory=process_factory,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
self.register_root = Path(register_root)
|
||||
self.python_executable = str(python_executable)
|
||||
self.exporter_path = Path(exporter_path or (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "scripts"
|
||||
/ "export_registered_sessions.py"
|
||||
))
|
||||
self.sessions_path = self.register_root / "keys" / "auth-sessions.jsonl"
|
||||
self.accounts_path = self.register_root / "keys" / "accounts.txt"
|
||||
|
||||
@staticmethod
|
||||
def _path_generation(path):
|
||||
try:
|
||||
stat_result = path.stat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
return (
|
||||
stat_result.st_dev,
|
||||
stat_result.st_ino,
|
||||
stat_result.st_size,
|
||||
stat_result.st_mtime_ns,
|
||||
)
|
||||
|
||||
def _input_generation(self):
|
||||
return (
|
||||
self._path_generation(self.sessions_path),
|
||||
self._path_generation(self.accounts_path),
|
||||
)
|
||||
|
||||
def _input_generation_unchanged(self, generation):
|
||||
return self._input_generation() == generation
|
||||
|
||||
def _empty_snapshot_allowed(self):
|
||||
return True
|
||||
|
||||
def _args(self):
|
||||
return [
|
||||
self.python_executable,
|
||||
str(self.exporter_path),
|
||||
str(self.sessions_path),
|
||||
str(self.accounts_path),
|
||||
]
|
||||
|
||||
|
||||
class DiskSnapshotSource:
|
||||
"""Consume immutable local snapshot generations under queue backpressure."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path,
|
||||
*,
|
||||
synchronizer=None,
|
||||
sync_seconds=30.0,
|
||||
poll_seconds=0.25,
|
||||
sleep=asyncio.sleep,
|
||||
event_callback=None,
|
||||
fingerprint=None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.synchronizer = synchronizer
|
||||
self.sync_seconds = float(sync_seconds)
|
||||
self.poll_seconds = float(poll_seconds)
|
||||
self.sleep = sleep
|
||||
self.event_callback = event_callback
|
||||
self.fingerprint = fingerprint or (lambda source_id: source_id)
|
||||
self._snapshot_fingerprints = None
|
||||
self._last_reported_snapshot_fingerprints = None
|
||||
self._closed = False
|
||||
self._sync_task = None
|
||||
self._last_sync_ok = None
|
||||
|
||||
def _emit(self, kind, data):
|
||||
if self.event_callback is not None:
|
||||
with suppress(Exception):
|
||||
self.event_callback(kind, data)
|
||||
|
||||
@property
|
||||
def snapshot_fingerprints(self):
|
||||
if self.synchronizer is not None:
|
||||
return self.synchronizer.snapshot_fingerprints
|
||||
return self._snapshot_fingerprints
|
||||
|
||||
async def _sync_loop(self):
|
||||
while not self._closed:
|
||||
refreshed = await self.synchronizer.sync_once()
|
||||
current = self.synchronizer.snapshot_fingerprints
|
||||
if refreshed != self._last_sync_ok:
|
||||
self._emit(
|
||||
"source_connected" if refreshed else "source_disconnected",
|
||||
{} if refreshed else {"reason": "snapshot_sync_failed"},
|
||||
)
|
||||
self._last_sync_ok = refreshed
|
||||
if refreshed and current is not None:
|
||||
previous = self._last_reported_snapshot_fingerprints
|
||||
if previous is not None:
|
||||
added = current - previous
|
||||
if added:
|
||||
self._emit(
|
||||
"source_updated",
|
||||
{"new": len(added), "total": len(current)},
|
||||
)
|
||||
self._last_reported_snapshot_fingerprints = current
|
||||
try:
|
||||
await asyncio.wait_for(self._wait_closed(), timeout=self.sync_seconds)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
async def _wait_closed(self):
|
||||
while not self._closed:
|
||||
await self.sleep(min(0.25, self.sync_seconds))
|
||||
|
||||
async def close(self):
|
||||
self._closed = True
|
||||
if self._sync_task is not None:
|
||||
self._sync_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._sync_task
|
||||
self._sync_task = None
|
||||
if self.synchronizer is not None:
|
||||
await self.synchronizer.close()
|
||||
|
||||
@staticmethod
|
||||
def _generation(stat_result):
|
||||
return (
|
||||
stat_result.st_dev,
|
||||
stat_result.st_ino,
|
||||
stat_result.st_mtime_ns,
|
||||
stat_result.st_size,
|
||||
)
|
||||
|
||||
async def records(self):
|
||||
if self.synchronizer is not None and self._sync_task is None:
|
||||
self._sync_task = asyncio.create_task(self._sync_loop())
|
||||
consumed_generation = None
|
||||
while not self._closed:
|
||||
try:
|
||||
current = self.path.stat()
|
||||
except FileNotFoundError:
|
||||
await self.sleep(self.poll_seconds)
|
||||
continue
|
||||
generation = self._generation(current)
|
||||
if generation == consumed_generation:
|
||||
await self.sleep(self.poll_seconds)
|
||||
continue
|
||||
try:
|
||||
stream = self.path.open("rb")
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
with stream:
|
||||
opened_generation = self._generation(os.fstat(stream.fileno()))
|
||||
generation_fingerprints = set()
|
||||
valid_generation = True
|
||||
while not self._closed:
|
||||
raw = await asyncio.to_thread(
|
||||
stream.readline, MAX_SESSION_RECORD_BYTES + 2
|
||||
)
|
||||
if not raw:
|
||||
break
|
||||
if (
|
||||
not raw.endswith(b"\n")
|
||||
or len(raw) - 1 > MAX_SESSION_RECORD_BYTES
|
||||
):
|
||||
self._emit(
|
||||
"source_record_rejected", {"reason": "invalid_record"}
|
||||
)
|
||||
valid_generation = False
|
||||
break
|
||||
try:
|
||||
record = parse_session_document(raw[:-1])
|
||||
except ValueError:
|
||||
self._emit(
|
||||
"source_record_rejected", {"reason": "invalid_record"}
|
||||
)
|
||||
valid_generation = False
|
||||
continue
|
||||
generation_fingerprints.add(self.fingerprint(record.source_id))
|
||||
yield record
|
||||
if valid_generation:
|
||||
self._snapshot_fingerprints = frozenset(generation_fingerprints)
|
||||
consumed_generation = opened_generation
|
||||
|
||||
|
||||
def parse_session_document(raw: bytes | str) -> SourceRecord:
|
||||
if len(raw) > MAX_SESSION_RECORD_BYTES:
|
||||
raise ValueError("invalid remote session record")
|
||||
try:
|
||||
document = json.loads(raw)
|
||||
source_id = document["email"]
|
||||
raw_cookies = document["cookies"]
|
||||
except (UnicodeDecodeError, TypeError, ValueError, KeyError) as exc:
|
||||
raise ValueError("invalid remote session record") from exc
|
||||
if not isinstance(source_id, str) or not source_id:
|
||||
raise ValueError("invalid remote session record")
|
||||
try:
|
||||
source_id.encode("utf-8")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ValueError("invalid remote session record") from exc
|
||||
if not isinstance(raw_cookies, list) or not raw_cookies:
|
||||
raise ValueError("invalid remote session record")
|
||||
|
||||
cookies = []
|
||||
sso_token = ""
|
||||
fallback_sso_token = ""
|
||||
allowed = {
|
||||
"name",
|
||||
"value",
|
||||
"url",
|
||||
"domain",
|
||||
"path",
|
||||
"expires",
|
||||
"httpOnly",
|
||||
"secure",
|
||||
"sameSite",
|
||||
}
|
||||
for raw_cookie in raw_cookies:
|
||||
if not isinstance(raw_cookie, dict):
|
||||
raise ValueError("invalid remote session record")
|
||||
cookie = {key: raw_cookie[key] for key in allowed if key in raw_cookie}
|
||||
name = cookie.get("name")
|
||||
value = cookie.get("value")
|
||||
if not isinstance(name, str) or not name or not isinstance(value, str) or not value:
|
||||
raise ValueError("invalid remote session record")
|
||||
scope = cookie.get("url") or cookie.get("domain")
|
||||
if not isinstance(scope, str) or not scope:
|
||||
raise ValueError("invalid remote session record")
|
||||
try:
|
||||
name.encode("utf-8")
|
||||
value.encode("utf-8")
|
||||
scope.encode("utf-8")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ValueError("invalid remote session record") from exc
|
||||
if name == "sso" and not sso_token:
|
||||
sso_token = value
|
||||
elif name == "sso-rw" and not fallback_sso_token:
|
||||
fallback_sso_token = value
|
||||
cookies.append(cookie)
|
||||
sso_token = sso_token or fallback_sso_token
|
||||
if not sso_token:
|
||||
raise ValueError("invalid remote session record")
|
||||
return SourceRecord(source_id, sso_token, tuple(cookies))
|
||||
|
||||
|
||||
class RemoteSessionStream:
|
||||
"""Yield full snapshots and appends from one reconnecting SSH child."""
|
||||
|
||||
MAX_STDERR_BYTES = 16 * 1024
|
||||
MAX_RECORD_BYTES = MAX_SESSION_RECORD_BYTES
|
||||
RECONNECT_DELAYS = (1.0, 2.0, 5.0, 10.0, 30.0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
remote_root: str = "/opt/grok-free-register",
|
||||
identity_file: str | None = None,
|
||||
process_factory=asyncio.create_subprocess_exec,
|
||||
sleep=asyncio.sleep,
|
||||
event_callback=None,
|
||||
):
|
||||
self.host = host
|
||||
self.remote_root = remote_root
|
||||
self.identity_file = identity_file
|
||||
self.process_factory = process_factory
|
||||
self.sleep = sleep
|
||||
self.event_callback = event_callback
|
||||
self._process = None
|
||||
self._closed = False
|
||||
self._last_disconnect_reason = None
|
||||
|
||||
def _emit(self, kind, data):
|
||||
if self.event_callback is None:
|
||||
return
|
||||
try:
|
||||
self.event_callback(kind, data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _command(self):
|
||||
return (
|
||||
f"cd {shlex.quote(self.remote_root)} && "
|
||||
"python3 -u scripts/export_registered_sessions.py --follow "
|
||||
"keys/auth-sessions.jsonl keys/accounts.txt"
|
||||
)
|
||||
|
||||
def _args(self):
|
||||
args = [
|
||||
"ssh",
|
||||
"-C",
|
||||
"-T",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=15",
|
||||
"-o",
|
||||
"ServerAliveInterval=15",
|
||||
"-o",
|
||||
"ServerAliveCountMax=3",
|
||||
]
|
||||
if self.identity_file:
|
||||
args.extend(["-i", self.identity_file])
|
||||
args.extend(["--", self.host, self._command()])
|
||||
return args
|
||||
|
||||
async def _read_stderr(self, stream):
|
||||
retained = bytearray()
|
||||
while True:
|
||||
chunk = await stream.read(4096)
|
||||
if not chunk:
|
||||
break
|
||||
remaining = self.MAX_STDERR_BYTES - len(retained)
|
||||
if remaining > 0:
|
||||
retained.extend(chunk[:remaining])
|
||||
return bytes(retained)
|
||||
|
||||
@staticmethod
|
||||
def _classify_disconnect(returncode, stderr):
|
||||
normalized = stderr.lower()
|
||||
if b"permission denied" in normalized or b"host key verification failed" in normalized:
|
||||
return "ssh_auth_failed"
|
||||
if b"could not resolve" in normalized or b"name or service not known" in normalized:
|
||||
return "ssh_resolution_failed"
|
||||
if any(
|
||||
marker in normalized
|
||||
for marker in (b"connection refused", b"connection timed out", b"no route to host")
|
||||
):
|
||||
return "ssh_connection_failed"
|
||||
if returncode == 3:
|
||||
return "remote_snapshot_changed"
|
||||
if returncode == 4:
|
||||
return "remote_data_invalid"
|
||||
return "remote_stream_closed"
|
||||
|
||||
async def _terminate_process(self, process):
|
||||
if process is None or process.returncode is not None:
|
||||
return
|
||||
with suppress(ProcessLookupError):
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=3)
|
||||
except TimeoutError:
|
||||
with suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
async def close(self):
|
||||
self._closed = True
|
||||
await self._terminate_process(self._process)
|
||||
|
||||
async def records(self):
|
||||
reconnect_index = 0
|
||||
while not self._closed:
|
||||
process = None
|
||||
stderr_task = None
|
||||
yielded = False
|
||||
reason = "remote_stream_closed"
|
||||
try:
|
||||
process = await self.process_factory(
|
||||
*self._args(),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
limit=self.MAX_RECORD_BYTES + 1,
|
||||
)
|
||||
self._process = process
|
||||
stderr_task = asyncio.create_task(self._read_stderr(process.stderr))
|
||||
self._emit("source_connected", {})
|
||||
while not self._closed:
|
||||
try:
|
||||
raw = await process.stdout.readline()
|
||||
except (ValueError, asyncio.LimitOverrunError):
|
||||
reason = "remote_record_too_large"
|
||||
break
|
||||
if not raw:
|
||||
break
|
||||
if (
|
||||
not raw.endswith(b"\n")
|
||||
or len(raw) - 1 > self.MAX_RECORD_BYTES
|
||||
):
|
||||
reason = "remote_record_too_large"
|
||||
break
|
||||
try:
|
||||
record = parse_session_document(raw[:-1])
|
||||
except ValueError:
|
||||
self._emit("source_record_rejected", {"reason": "invalid_record"})
|
||||
continue
|
||||
if not yielded:
|
||||
self._last_disconnect_reason = None
|
||||
yielded = True
|
||||
reconnect_index = 0
|
||||
yield record
|
||||
|
||||
# A follow exporter is intentionally long-lived. Once framing is
|
||||
# invalid, waiting for it to exit on its own would hang this source
|
||||
# forever, so terminate it before collecting the exit status.
|
||||
if reason != "remote_stream_closed":
|
||||
await self._terminate_process(process)
|
||||
if process.returncode is None:
|
||||
await process.wait()
|
||||
stderr = await stderr_task
|
||||
if reason == "remote_stream_closed":
|
||||
reason = self._classify_disconnect(process.returncode, stderr)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except (OSError, RuntimeError):
|
||||
reason = "ssh_start_failed"
|
||||
finally:
|
||||
if stderr_task is not None and not stderr_task.done():
|
||||
stderr_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await stderr_task
|
||||
await self._terminate_process(process)
|
||||
if self._process is process:
|
||||
self._process = None
|
||||
|
||||
if self._closed:
|
||||
break
|
||||
if reason != self._last_disconnect_reason:
|
||||
self._emit("source_disconnected", {"reason": reason})
|
||||
self._last_disconnect_reason = reason
|
||||
if reason == "remote_snapshot_changed":
|
||||
delay = 0.1
|
||||
else:
|
||||
delay = self.RECONNECT_DELAYS[min(reconnect_index, len(self.RECONNECT_DELAYS) - 1)]
|
||||
if not yielded:
|
||||
reconnect_index += 1
|
||||
await self.sleep(delay)
|
||||
|
||||
|
||||
PersistentSSHSource = RemoteSessionStream
|
||||
883
xai_enroller/service.py
Normal file
883
xai_enroller/service.py
Normal file
@@ -0,0 +1,883 @@
|
||||
"""Interactive composition for the asynchronous local authentication service."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import tempfile
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .inventory import InventoryError
|
||||
|
||||
|
||||
DEFAULT_LOCAL_AUTH_DIR = Path.home() / "Downloads" / "grok-free-register-auth"
|
||||
AUTHENTICATED_DIRNAME = "authenticated"
|
||||
CLAIMED_DIRNAME = "claimed"
|
||||
|
||||
|
||||
class AuthServiceConfigurationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class InteractiveCommandPrompt:
|
||||
"""Stdlib-only line editor that keeps commands visible during events."""
|
||||
|
||||
CLEAR_LINE = "\r\x1b[2K"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
input_stream=None,
|
||||
output=None,
|
||||
interactive=None,
|
||||
prompt="认证> ",
|
||||
):
|
||||
self.input_stream = input_stream or sys.stdin
|
||||
self.output = output or sys.stdout
|
||||
self.prompt = prompt
|
||||
if interactive is None:
|
||||
interactive = bool(
|
||||
getattr(self.input_stream, "isatty", lambda: False)()
|
||||
and getattr(self.output, "isatty", lambda: False)()
|
||||
)
|
||||
self.interactive = interactive
|
||||
self.buffer = ""
|
||||
self.active = False
|
||||
self._callback = None
|
||||
self._fd = None
|
||||
self._original_terminal = None
|
||||
|
||||
def _write(self, text):
|
||||
try:
|
||||
self.output.write(text)
|
||||
self.output.flush()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
def _redraw(self):
|
||||
if self.active and self.interactive:
|
||||
self._write(f"{self.CLEAR_LINE}{self.prompt}{self.buffer}")
|
||||
|
||||
def start(self, callback):
|
||||
self._callback = callback
|
||||
self.active = True
|
||||
self._redraw()
|
||||
|
||||
def feed(self, text):
|
||||
for character in text:
|
||||
if character in {"\r", "\n"}:
|
||||
command = self.buffer
|
||||
self.buffer = ""
|
||||
self._write("\n")
|
||||
if command.strip() and self._callback is not None:
|
||||
self._callback(command)
|
||||
if self.active and self.interactive:
|
||||
self._write(self.prompt)
|
||||
elif character in {"\b", "\x7f"}:
|
||||
self.buffer = self.buffer[:-1]
|
||||
self._redraw()
|
||||
elif character == "\x04":
|
||||
if self._callback is not None:
|
||||
self._callback(None)
|
||||
elif character.isprintable():
|
||||
self.buffer += character
|
||||
self._redraw()
|
||||
|
||||
def write_event(self, message):
|
||||
if self.active and self.interactive:
|
||||
self._write(f"{self.CLEAR_LINE}{message}\n{self.prompt}{self.buffer}")
|
||||
else:
|
||||
self._write(f"{message}\n")
|
||||
|
||||
def attach(self, loop, callback):
|
||||
self.start(callback)
|
||||
self._fd = self.input_stream.fileno()
|
||||
if self.interactive:
|
||||
import termios
|
||||
import tty
|
||||
|
||||
self._original_terminal = termios.tcgetattr(self._fd)
|
||||
tty.setcbreak(self._fd)
|
||||
|
||||
def stdin_ready():
|
||||
data = os.read(self._fd, 256)
|
||||
if data:
|
||||
self.feed(data.decode(errors="ignore"))
|
||||
elif self._callback is not None:
|
||||
loop.remove_reader(self._fd)
|
||||
self._callback(None)
|
||||
|
||||
else:
|
||||
|
||||
def stdin_ready():
|
||||
line = self.input_stream.readline()
|
||||
if line == "":
|
||||
if self._callback is not None:
|
||||
loop.remove_reader(self._fd)
|
||||
self._callback(None)
|
||||
return
|
||||
if self._callback is not None:
|
||||
self._callback(line)
|
||||
|
||||
loop.add_reader(self._fd, stdin_ready)
|
||||
|
||||
def close(self, loop):
|
||||
if self._fd is not None:
|
||||
with suppress(Exception):
|
||||
loop.remove_reader(self._fd)
|
||||
if self._original_terminal is not None and self._fd is not None:
|
||||
import termios
|
||||
|
||||
with suppress(Exception):
|
||||
termios.tcsetattr(
|
||||
self._fd, termios.TCSADRAIN, self._original_terminal
|
||||
)
|
||||
if self.active and self.interactive:
|
||||
self._write(f"{self.CLEAR_LINE}\n")
|
||||
self.active = False
|
||||
|
||||
|
||||
def resolve_auth_log_mode(argv=None, env=None):
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
env = dict(os.environ if env is None else env)
|
||||
unknown = [argument for argument in argv if argument != "--debug"]
|
||||
if unknown:
|
||||
raise AuthServiceConfigurationError(
|
||||
f"unsupported auth service argument: {unknown[0]}"
|
||||
)
|
||||
mode = (env.get("XAI_AUTH_SERVICE_LOG_MODE") or "user").strip().lower()
|
||||
if "--debug" in argv:
|
||||
mode = "debug"
|
||||
if mode not in {"user", "debug"}:
|
||||
raise AuthServiceConfigurationError(
|
||||
"XAI_AUTH_SERVICE_LOG_MODE must be user or debug"
|
||||
)
|
||||
return mode
|
||||
|
||||
|
||||
def prepare_local_service_environment(env=None):
|
||||
"""Create private local persistence defaults without storing secrets in the repo."""
|
||||
merged = dict(os.environ if env is None else env)
|
||||
destination = Path(
|
||||
merged.get("XAI_ENROLLER_LOCAL_AUTH_DIR", DEFAULT_LOCAL_AUTH_DIR)
|
||||
).expanduser()
|
||||
destination.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(destination, 0o700)
|
||||
merged["XAI_ENROLLER_SINK"] = "local"
|
||||
merged["XAI_ENROLLER_LOCAL_AUTH_DIR"] = str(destination)
|
||||
merged.setdefault(
|
||||
"XAI_ENROLLER_LEDGER_PATH", str(destination / "enrollment-ledger.db")
|
||||
)
|
||||
if not merged.get("XAI_ENROLLER_SOURCE_SALT"):
|
||||
salt_file = destination / ".ledger-salt"
|
||||
try:
|
||||
salt = salt_file.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
salt = secrets.token_hex(32)
|
||||
fd, temporary_name = tempfile.mkstemp(
|
||||
prefix=".ledger-salt.", suffix=".tmp", dir=destination, text=True
|
||||
)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
stream.write(salt + "\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary_name, salt_file)
|
||||
finally:
|
||||
if os.path.exists(temporary_name):
|
||||
os.unlink(temporary_name)
|
||||
os.chmod(salt_file, 0o600)
|
||||
merged["XAI_ENROLLER_SOURCE_SALT"] = salt
|
||||
return merged
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthServiceSettings:
|
||||
source_kind: str
|
||||
ssh_host: str | None
|
||||
register_root: str
|
||||
remote_root: str
|
||||
identity_file: str | None
|
||||
sync_seconds: int = 30
|
||||
retry_seconds: int = 60
|
||||
min_authorization_interval_seconds: float = 10.0
|
||||
|
||||
@classmethod
|
||||
def from_environ(cls, env=None):
|
||||
env = dict(os.environ if env is None else env)
|
||||
ssh_host = (env.get("XAI_AUTH_SERVICE_SSH_HOST") or "").strip()
|
||||
requested_source = (env.get("XAI_AUTH_SERVICE_SOURCE") or "auto").strip().lower()
|
||||
if requested_source not in {"auto", "local", "ssh"}:
|
||||
raise AuthServiceConfigurationError(
|
||||
"XAI_AUTH_SERVICE_SOURCE must be auto, local, or ssh"
|
||||
)
|
||||
source_kind = (
|
||||
"ssh" if requested_source == "auto" and ssh_host else
|
||||
"local" if requested_source == "auto" else
|
||||
requested_source
|
||||
)
|
||||
if source_kind == "ssh" and not ssh_host:
|
||||
raise AuthServiceConfigurationError("XAI_AUTH_SERVICE_SSH_HOST is required")
|
||||
register_root = (
|
||||
env.get("XAI_AUTH_SERVICE_REGISTER_ROOT")
|
||||
or str(Path(__file__).resolve().parents[1])
|
||||
).strip()
|
||||
remote_root = (
|
||||
env.get("XAI_AUTH_SERVICE_REMOTE_ROOT") or "/opt/grok-free-register"
|
||||
).strip()
|
||||
identity_file = (env.get("XAI_AUTH_SERVICE_SSH_IDENTITY") or "").strip() or None
|
||||
try:
|
||||
sync_seconds = int(env.get("XAI_AUTH_SERVICE_SYNC_SEC", "30"))
|
||||
retry_seconds = int(env.get("XAI_AUTH_SERVICE_RETRY_SEC", "60"))
|
||||
min_authorization_interval_seconds = float(
|
||||
env.get("XAI_AUTH_SERVICE_MIN_INTERVAL_SEC", "10")
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise AuthServiceConfigurationError(
|
||||
"auth service intervals must be numeric"
|
||||
) from exc
|
||||
if not 5 <= sync_seconds <= 3600:
|
||||
raise AuthServiceConfigurationError(
|
||||
"XAI_AUTH_SERVICE_SYNC_SEC must be between 5 and 3600"
|
||||
)
|
||||
if not 30 <= retry_seconds <= 86400:
|
||||
raise AuthServiceConfigurationError(
|
||||
"XAI_AUTH_SERVICE_RETRY_SEC must be between 30 and 86400"
|
||||
)
|
||||
if not 0 <= min_authorization_interval_seconds <= 3600:
|
||||
raise AuthServiceConfigurationError(
|
||||
"XAI_AUTH_SERVICE_MIN_INTERVAL_SEC must be between 0 and 3600"
|
||||
)
|
||||
return cls(
|
||||
source_kind=source_kind,
|
||||
ssh_host=ssh_host or None,
|
||||
register_root=register_root,
|
||||
remote_root=remote_root,
|
||||
identity_file=identity_file,
|
||||
sync_seconds=sync_seconds,
|
||||
retry_seconds=retry_seconds,
|
||||
min_authorization_interval_seconds=min_authorization_interval_seconds,
|
||||
)
|
||||
|
||||
|
||||
class EventTerminal:
|
||||
"""Mode-aware aggregate renderer; identifiers and credentials stay hidden."""
|
||||
|
||||
_SAFE_REASONS = {
|
||||
"already_imported",
|
||||
"authorization_transport_failed",
|
||||
"browser_error",
|
||||
"challenge_required",
|
||||
"confirmation_timeout",
|
||||
"device_flow_failed",
|
||||
"device_flow_invalid",
|
||||
"device_flow_refresh_failed",
|
||||
"imported",
|
||||
"internal_error",
|
||||
"oauth_denied",
|
||||
"oauth_expired",
|
||||
"oauth_rejected",
|
||||
"operator_cancelled",
|
||||
"rate_limited",
|
||||
"sink_failed",
|
||||
"sink_unconfigured",
|
||||
"snapshot_sync_failed",
|
||||
"token_failed",
|
||||
"token_transport_failed",
|
||||
"unknown_page",
|
||||
}
|
||||
_TRANSIENT_REASONS = {
|
||||
"authorization_transport_failed",
|
||||
"browser_error",
|
||||
"challenge_required",
|
||||
"confirmation_timeout",
|
||||
"device_flow_failed",
|
||||
"device_flow_refresh_failed",
|
||||
"sink_failed",
|
||||
"token_failed",
|
||||
"token_transport_failed",
|
||||
"unknown_page",
|
||||
}
|
||||
|
||||
def __init__(self, mode="user", output=None):
|
||||
if mode not in {"user", "debug"}:
|
||||
raise ValueError("terminal mode must be user or debug")
|
||||
self.mode = mode
|
||||
self.output = output or self._print
|
||||
|
||||
@staticmethod
|
||||
def _print(message):
|
||||
print(message, flush=True)
|
||||
|
||||
@staticmethod
|
||||
def _percentage(value):
|
||||
return f"{100.0 * float(value):.1f}%"
|
||||
|
||||
@staticmethod
|
||||
def _rate(value):
|
||||
return "—" if value is None else f"{float(value):.2f}/分"
|
||||
|
||||
@staticmethod
|
||||
def _pending(value):
|
||||
return "—" if value is None else str(value)
|
||||
|
||||
@staticmethod
|
||||
def _task_suffix(value):
|
||||
return "" if value is None else f" #{value}"
|
||||
|
||||
def _format_user(self, kind, data):
|
||||
if kind == "startup":
|
||||
source = {"local": "本机", "ssh": "远端"}.get(
|
||||
data.get("source_kind"), "等待同步"
|
||||
)
|
||||
return (
|
||||
f"[✓] 本地认证服务已启动 | 来源 {source} | "
|
||||
f"输出 {data['destination']} | 待处理 — | 可用 {data['available']}"
|
||||
)
|
||||
if kind == "service_started":
|
||||
return "[▶] 认证流水线运行中"
|
||||
if kind == "service_stopped":
|
||||
return "[■] 认证服务已停止"
|
||||
if kind == "source_connected":
|
||||
return "[✓] 账号来源已连接 | 本地快照可用"
|
||||
if kind == "source_updated":
|
||||
return f"[↻] 发现新账号 {data['new']} | 快照共 {data['total']}"
|
||||
if kind == "source_disconnected":
|
||||
return "[!] 账号来源暂时断开 | 继续使用上一份有效快照"
|
||||
if kind == "source_record_rejected":
|
||||
return "[!] 已忽略一条无效来源记录"
|
||||
if kind == "rate_limited":
|
||||
pace = data.get("min_authorization_interval_seconds")
|
||||
pace_text = (
|
||||
f" | 恢复后间隔 {pace:.0f}秒" if pace is not None else ""
|
||||
)
|
||||
return (
|
||||
f"[⏸] 触发限流 | {data['wait_seconds']}秒后单次探测{pace_text}"
|
||||
)
|
||||
if kind == "rate_limit_cleared":
|
||||
return f"[▶] 限流解除 | 实际等待 {data['elapsed_seconds']}秒"
|
||||
if kind == "pacing_adjusted":
|
||||
return (
|
||||
f"[•] 认证间隔调整为 {data['min_authorization_interval_seconds']:.0f}秒"
|
||||
)
|
||||
if kind == "authorization_started":
|
||||
return (
|
||||
f"[→] 开始认证 #{data['task_number']} | "
|
||||
f"待处理 {self._pending(data.get('pending_total'))}"
|
||||
)
|
||||
if kind == "result":
|
||||
if data.get("status") == "imported":
|
||||
return (
|
||||
f"[✓] 认证成功{self._task_suffix(data.get('task_number'))} | "
|
||||
f"运行平均 {self._rate(data.get('lifetime_imports_per_minute'))} | "
|
||||
f"累计 {data['imported_unique']} | 可用 {data.get('available', '—')}"
|
||||
)
|
||||
reason = data.get("reason")
|
||||
if reason == "rate_limited":
|
||||
return None
|
||||
action = "暂时失败,将自动重试" if reason in self._TRANSIENT_REASONS else "已跳过"
|
||||
return f"[✗] 认证未完成{self._task_suffix(data.get('task_number'))} | {action}"
|
||||
if kind == "control":
|
||||
states = {
|
||||
"paused": "[⏸] 认证服务已暂停",
|
||||
"running": "[▶] 认证服务已恢复",
|
||||
"cancelling": "[■] 正在取消当前任务",
|
||||
"idle": "[•] 当前没有可取消的任务",
|
||||
"stopping": "[■] 正在安全退出",
|
||||
"usage: take <positive-count>": "[!] 用法:take N,N 必须是正整数",
|
||||
"inventory unavailable": "[!] 当前凭据库存不可用",
|
||||
"help": (
|
||||
"[•] 命令 s 状态 | take N 取用 | p 暂停 | "
|
||||
"r 恢复 | c 取消 | q 退出"
|
||||
),
|
||||
"unknown": "[!] 未知命令 | 输入 help 查看命令",
|
||||
}
|
||||
return states.get(data.get("state"), "[•] 控制命令已处理")
|
||||
if kind == "status":
|
||||
state = {"running": "运行中", "paused": "已暂停", "stopping": "停止中"}.get(
|
||||
data.get("state"), "未知"
|
||||
)
|
||||
cooldown = (
|
||||
f"限流冷却 {data.get('cooldown_remaining_seconds', 0):.0f}秒"
|
||||
if data.get("cooldown")
|
||||
else "不限流"
|
||||
)
|
||||
return (
|
||||
f"[•] 状态 {state} | 待处理 {self._pending(data.get('pending_total'))} | "
|
||||
f"阶段 {data.get('active_stage', 'idle')} | "
|
||||
f"运行平均 {self._rate(data.get('lifetime_imports_per_minute'))} | "
|
||||
f"累计 {data.get('imported_unique', 0)} | "
|
||||
f"可用 {data.get('available', 0)} | 已取用 {data.get('claimed', 0)} | {cooldown}"
|
||||
)
|
||||
if kind == "inventory_taken":
|
||||
return (
|
||||
f"[✓] 已取用 {data['moved']} 个凭据 | "
|
||||
f"剩余可用 {data['available']} | "
|
||||
f"位置 claimed/{data['batch_id']}/"
|
||||
)
|
||||
if kind == "inventory_error":
|
||||
return (
|
||||
f"[!] 凭据取用失败 | 可用 {data['available']} | "
|
||||
f"处理中 {data['claiming']} | 已取用 {data['claimed']}"
|
||||
)
|
||||
if kind == "pipeline_error":
|
||||
return f"[!] 认证流水线异常 | 阶段 {data.get('stage', 'unknown')}"
|
||||
return None
|
||||
|
||||
def _format_debug(self, kind, data):
|
||||
if kind == "service_started":
|
||||
return (
|
||||
"• service: running; "
|
||||
f"min_interval={data['min_authorization_interval_seconds']:.1f}s"
|
||||
)
|
||||
if kind == "service_stopped":
|
||||
return "• service: stopped"
|
||||
if kind == "source_connected":
|
||||
return "• source: local snapshot updated"
|
||||
if kind == "source_updated":
|
||||
return f"• source: new={data['new']}; total={data['total']}"
|
||||
if kind == "source_disconnected":
|
||||
return "⚠ source: snapshot_sync_failed; keeping previous snapshot"
|
||||
if kind == "source_record_rejected":
|
||||
return "⚠ source: invalid record rejected"
|
||||
if kind == "rate_limited":
|
||||
pace = data.get("min_authorization_interval_seconds")
|
||||
pace_text = f"; resume_interval={pace:.1f}s" if pace is not None else ""
|
||||
return (
|
||||
"⏸ authentication rate limited; "
|
||||
f"next single probe in {data['wait_seconds']}s{pace_text}"
|
||||
)
|
||||
if kind == "rate_limit_cleared":
|
||||
return (
|
||||
"▶ authentication rate limit cleared after "
|
||||
f"{data['elapsed_seconds']}s"
|
||||
)
|
||||
if kind == "pacing_adjusted":
|
||||
return (
|
||||
"• pacing: min_interval="
|
||||
f"{data['min_authorization_interval_seconds']:.1f}s"
|
||||
f" ({data.get('reason', 'adjusted')})"
|
||||
)
|
||||
if kind == "authorization_started":
|
||||
return (
|
||||
"→ authentication: next task started; "
|
||||
f"task={data['task_number']}; attempt={data['attempt_number']}; "
|
||||
f"queued={data['source_queue']}; "
|
||||
f"pending={self._pending(data.get('pending_total'))}"
|
||||
)
|
||||
if kind == "result":
|
||||
if data.get("status") == "imported":
|
||||
return (
|
||||
"✓ authentication: imported; "
|
||||
f"task={data.get('task_number')}; total={data['imported_unique']}; "
|
||||
f"5m_rate={self._rate(data.get('five_minute_imports_per_minute'))}; "
|
||||
f"attempt_success={self._percentage(data['attempt_success'])}; "
|
||||
f"eventual_success={self._percentage(data['eventual_success'])}"
|
||||
)
|
||||
reason = data.get("reason")
|
||||
safe_reason = reason if reason in self._SAFE_REASONS else "unknown"
|
||||
return (
|
||||
f"⚠ authentication: {data.get('status', 'unknown')} ({safe_reason}); "
|
||||
f"task={data.get('task_number')}; attempt={data.get('attempt_number')}"
|
||||
)
|
||||
if kind == "control":
|
||||
state = data.get("state", "unknown")
|
||||
safe_state = state if str(state).replace(" ", "").isalnum() else "unknown"
|
||||
return f"• service: {safe_state}"
|
||||
if kind == "status":
|
||||
rate = data.get("five_minute_imports_per_minute")
|
||||
rate_text = "unknown" if rate is None else f"{rate:.2f}/min"
|
||||
lifetime_rate = data.get("lifetime_imports_per_minute")
|
||||
lifetime_rate_text = (
|
||||
"unknown"
|
||||
if lifetime_rate is None
|
||||
else f"{lifetime_rate:.2f}/min"
|
||||
)
|
||||
return (
|
||||
f"• status: {data['state']}; "
|
||||
f"queues={data['source_queue']}/{data['prepared_queue']}/"
|
||||
f"{data['completion_queue']}; active={data['active_stage']}; "
|
||||
f"retry_waiting={data['retry_waiting']}; "
|
||||
f"next_retry={data['next_retry_seconds']:.1f}s; "
|
||||
f"started={data['authorization_starts']}; "
|
||||
f"cooldown={str(data['cooldown']).lower()}; "
|
||||
f"cooldown_remaining={data['cooldown_remaining_seconds']:.1f}s; "
|
||||
f"probe={str(data['probe_in_flight']).lower()}; "
|
||||
f"min_interval={data['min_authorization_interval_seconds']:.1f}s; "
|
||||
f"pace_remaining={data['pacing_remaining_seconds']:.1f}s; "
|
||||
f"imported={data['imported_unique']}; attempted={data['attempted_unique']}; "
|
||||
f"rate_limited={data['rate_limited']}; 5m_rate={rate_text}; "
|
||||
f"lifetime_rate={lifetime_rate_text}; "
|
||||
f"available={data['available']}; claiming={data['claiming']}; "
|
||||
f"claimed={data['claimed']}"
|
||||
)
|
||||
if kind == "inventory_taken":
|
||||
return (
|
||||
f"✓ inventory: claimed={data['moved']}; "
|
||||
f"available={data['available']}; batch={data['batch_id']}"
|
||||
)
|
||||
if kind == "inventory_error":
|
||||
return (
|
||||
f"⚠ inventory: available={data['available']}; "
|
||||
f"claiming={data['claiming']}; claimed={data['claimed']}"
|
||||
)
|
||||
if kind == "pipeline_error":
|
||||
reason = data.get("reason")
|
||||
safe_reason = reason if reason in self._SAFE_REASONS else "unknown"
|
||||
stage = data.get("stage", "unknown")
|
||||
safe_stage = stage if str(stage).replace("_", "").isalnum() else "unknown"
|
||||
return f"⚠ service: {safe_stage} stage stopped ({safe_reason})"
|
||||
known = self._format_user(kind, data)
|
||||
if known is not None:
|
||||
return known
|
||||
reason = data.get("reason")
|
||||
suffix = f" reason={reason}" if reason in self._SAFE_REASONS else ""
|
||||
safe_kind = kind if kind.replace("_", "").isalnum() else "unknown"
|
||||
return f"• debug event={safe_kind}{suffix}"
|
||||
|
||||
def emit(self, event):
|
||||
kind, data = event
|
||||
try:
|
||||
message = (
|
||||
self._format_debug(kind, data)
|
||||
if self.mode == "debug"
|
||||
else self._format_user(kind, data)
|
||||
)
|
||||
if message is not None:
|
||||
self.output(message)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
class AuthPipelineRunner:
|
||||
"""Map interactive controls onto the persistent pipeline lifecycle."""
|
||||
|
||||
def __init__(self, pipeline, emit, *, interval_seconds=None, inventory=None):
|
||||
self.pipeline = pipeline
|
||||
self.emit = emit
|
||||
self.inventory = inventory
|
||||
self.paused = False
|
||||
self.current_cycle = None
|
||||
|
||||
async def handle_command(self, command):
|
||||
command = command.strip().lower()
|
||||
if command == "p":
|
||||
self.paused = True
|
||||
self.pipeline.pause()
|
||||
self.emit(("control", {"state": "paused"}))
|
||||
elif command == "r":
|
||||
self.paused = False
|
||||
self.pipeline.resume()
|
||||
self.emit(("control", {"state": "running"}))
|
||||
elif command == "s":
|
||||
status = self.pipeline.status()
|
||||
status.update(self.pipeline.ledger.inventory_counts())
|
||||
self.emit(("status", status))
|
||||
elif command.startswith("take "):
|
||||
parts = command.split()
|
||||
if len(parts) != 2 or not parts[1].isdigit() or int(parts[1]) <= 0:
|
||||
self.emit(("control", {"state": "usage: take <positive-count>"}))
|
||||
return True
|
||||
if self.inventory is None:
|
||||
self.emit(("control", {"state": "inventory unavailable"}))
|
||||
return True
|
||||
try:
|
||||
batch = await asyncio.to_thread(self.inventory.take, int(parts[1]))
|
||||
except InventoryError as exc:
|
||||
self.emit(
|
||||
(
|
||||
"inventory_error",
|
||||
{
|
||||
"reason": str(exc),
|
||||
**self.pipeline.ledger.inventory_counts(),
|
||||
},
|
||||
)
|
||||
)
|
||||
return True
|
||||
counts = self.pipeline.ledger.inventory_counts()
|
||||
self.emit(
|
||||
(
|
||||
"inventory_taken",
|
||||
{
|
||||
"batch_id": batch.batch_id,
|
||||
"directory": str(batch.directory),
|
||||
"moved": batch.moved,
|
||||
**counts,
|
||||
},
|
||||
)
|
||||
)
|
||||
elif command == "c":
|
||||
cancelled = await self.pipeline.cancel_active()
|
||||
self.emit(
|
||||
("control", {"state": "cancelling" if cancelled else "idle"})
|
||||
)
|
||||
elif command in {"help", "h", "?"}:
|
||||
self.emit(("control", {"state": "help"}))
|
||||
elif command in {"q", "quit", "exit"}:
|
||||
self.pipeline.request_stop()
|
||||
self.emit(("control", {"state": "stopping"}))
|
||||
return False
|
||||
elif command:
|
||||
self.emit(("control", {"state": "unknown"}))
|
||||
return True
|
||||
|
||||
async def run(self):
|
||||
self.current_cycle = asyncio.create_task(self.pipeline.run())
|
||||
try:
|
||||
await self.current_cycle
|
||||
finally:
|
||||
self.current_cycle = None
|
||||
|
||||
|
||||
async def _run_interactive(runner, terminal):
|
||||
loop = asyncio.get_running_loop()
|
||||
commands = asyncio.Queue()
|
||||
prompt = InteractiveCommandPrompt()
|
||||
previous_output = terminal.output
|
||||
terminal.output = prompt.write_event
|
||||
prompt.write_event(
|
||||
"命令:s 状态 | take N 取用凭据 | p 暂停 | r 恢复 | c 取消当前任务 | q 退出"
|
||||
)
|
||||
try:
|
||||
prompt.attach(loop, commands.put_nowait)
|
||||
except Exception:
|
||||
prompt.close(loop)
|
||||
terminal.output = previous_output
|
||||
raise
|
||||
worker = asyncio.create_task(runner.run())
|
||||
try:
|
||||
while not worker.done():
|
||||
command_task = asyncio.create_task(commands.get())
|
||||
done, _pending = await asyncio.wait(
|
||||
{worker, command_task}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if worker in done:
|
||||
command_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await command_task
|
||||
await worker
|
||||
break
|
||||
command = command_task.result()
|
||||
if command is None:
|
||||
command = "q"
|
||||
if not await runner.handle_command(command):
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
runner.pipeline.request_stop()
|
||||
raise
|
||||
finally:
|
||||
prompt.close(loop)
|
||||
terminal.output = previous_output
|
||||
runner.pipeline.request_stop()
|
||||
await asyncio.gather(worker, return_exceptions=True)
|
||||
|
||||
|
||||
async def main_async(*, log_mode="user"):
|
||||
import httpx
|
||||
|
||||
from .auth_pipeline import AuthPipeline
|
||||
from .config import Settings
|
||||
from .executors import PlaywrightExecutor
|
||||
from .inventory import CredentialInventory
|
||||
from .ledger import Ledger
|
||||
from .protocol import XAIProfile, XAIProtocol
|
||||
from .remote_stream import (
|
||||
DiskSnapshotSource,
|
||||
LocalSnapshotSynchronizer,
|
||||
SSHSnapshotSynchronizer,
|
||||
)
|
||||
from .sinks import LocalAuthFileSink
|
||||
|
||||
service_settings = AuthServiceSettings.from_environ()
|
||||
merged = prepare_local_service_environment()
|
||||
merged["XAI_ENROLLER_SOURCE_KIND"] = "remote"
|
||||
merged["XAI_ENROLLER_AUTH_EXECUTOR"] = "playwright"
|
||||
merged["XAI_ENROLLER_CONCURRENCY"] = "1"
|
||||
settings = Settings.from_environ(merged)
|
||||
terminal = EventTerminal(mode=log_mode)
|
||||
|
||||
# CF prewarm via external FlareSolverr (:8191). Separate from REGISTER_PROXY.
|
||||
try:
|
||||
from grok_register.clearance import (
|
||||
format_prewarm_log,
|
||||
httpx_proxy_mounts,
|
||||
prewarm_clearance,
|
||||
register_proxy_url,
|
||||
)
|
||||
|
||||
prewarm = await asyncio.to_thread(prewarm_clearance)
|
||||
terminal.emit(("control", {"state": format_prewarm_log(prewarm)}))
|
||||
reg_proxy = register_proxy_url()
|
||||
if reg_proxy:
|
||||
terminal.emit(("control", {"state": f"register_proxy={reg_proxy}"}))
|
||||
proxy_url = httpx_proxy_mounts()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
terminal.emit(("control", {"state": f"clearance_prewarm_skip: {exc}"}))
|
||||
proxy_url = None
|
||||
|
||||
# httpx honors REGISTER_PROXY/HTTP_PROXY only when passed explicitly
|
||||
client = httpx.AsyncClient(proxy=proxy_url) if proxy_url else httpx.AsyncClient()
|
||||
pipeline = None
|
||||
try:
|
||||
ledger = Ledger(settings.ledger_path, settings.source_salt)
|
||||
snapshot_path = Path(settings.local_auth_dir) / "source-snapshot.jsonl"
|
||||
if service_settings.source_kind == "ssh":
|
||||
synchronizer = SSHSnapshotSynchronizer(
|
||||
service_settings.ssh_host,
|
||||
snapshot_path,
|
||||
remote_root=service_settings.remote_root,
|
||||
identity_file=service_settings.identity_file,
|
||||
fingerprint=ledger.fingerprint,
|
||||
)
|
||||
else:
|
||||
synchronizer = LocalSnapshotSynchronizer(
|
||||
service_settings.register_root,
|
||||
snapshot_path,
|
||||
fingerprint=ledger.fingerprint,
|
||||
)
|
||||
source = DiskSnapshotSource(
|
||||
snapshot_path,
|
||||
synchronizer=synchronizer,
|
||||
sync_seconds=service_settings.sync_seconds,
|
||||
event_callback=lambda kind, data: terminal.emit((kind, data)),
|
||||
fingerprint=ledger.fingerprint,
|
||||
)
|
||||
protocol = XAIProtocol(
|
||||
client,
|
||||
XAIProfile.default(),
|
||||
default_poll_interval=settings.poll_interval,
|
||||
)
|
||||
executor = PlaywrightExecutor(concurrency=1)
|
||||
sink = LocalAuthFileSink(
|
||||
Path(settings.local_auth_dir) / AUTHENTICATED_DIRNAME,
|
||||
name_secret=settings.source_salt,
|
||||
)
|
||||
inventory = CredentialInventory(
|
||||
ledger,
|
||||
Path(settings.local_auth_dir) / AUTHENTICATED_DIRNAME,
|
||||
Path(settings.local_auth_dir) / CLAIMED_DIRNAME,
|
||||
)
|
||||
recovered = await asyncio.to_thread(inventory.recover)
|
||||
if recovered:
|
||||
terminal.emit(("control", {"state": f"recovered {recovered} claims"}))
|
||||
terminal.emit(
|
||||
(
|
||||
"startup",
|
||||
{
|
||||
"destination": f"{AUTHENTICATED_DIRNAME}/",
|
||||
"source_kind": service_settings.source_kind,
|
||||
**ledger.inventory_counts(),
|
||||
},
|
||||
)
|
||||
)
|
||||
pipeline = AuthPipeline(
|
||||
source=source,
|
||||
protocol=protocol,
|
||||
executor=executor,
|
||||
sink=sink,
|
||||
ledger=ledger,
|
||||
timeout=settings.timeout_sec,
|
||||
min_authorization_interval=(
|
||||
service_settings.min_authorization_interval_seconds
|
||||
),
|
||||
event_callback=lambda kind, data: terminal.emit((kind, data)),
|
||||
)
|
||||
# Keep base cooldown configurable; the gate grows it after consecutive trips.
|
||||
pipeline.rate_gate.base_cooldown = float(service_settings.retry_seconds)
|
||||
pipeline.rate_gate.COOLDOWN_SECONDS = float(service_settings.retry_seconds)
|
||||
pipeline.rate_gate._current_cooldown = float(service_settings.retry_seconds)
|
||||
runner = AuthPipelineRunner(pipeline, terminal.emit, inventory=inventory)
|
||||
await _run_interactive(runner, terminal)
|
||||
finally:
|
||||
if pipeline is not None:
|
||||
pipeline.request_stop()
|
||||
await client.aclose()
|
||||
|
||||
|
||||
def _known_configuration_key(error):
|
||||
message = str(error)
|
||||
keys = (
|
||||
"XAI_AUTH_SERVICE_LOG_MODE",
|
||||
"XAI_AUTH_SERVICE_SOURCE",
|
||||
"XAI_AUTH_SERVICE_SSH_HOST",
|
||||
"XAI_AUTH_SERVICE_REGISTER_ROOT",
|
||||
"XAI_AUTH_SERVICE_SYNC_SEC",
|
||||
"XAI_AUTH_SERVICE_RETRY_SEC",
|
||||
"XAI_AUTH_SERVICE_MIN_INTERVAL_SEC",
|
||||
"XAI_ENROLLER_TARGET",
|
||||
"XAI_ENROLLER_RETRY_ATTEMPTS",
|
||||
"XAI_ENROLLER_TIMEOUT_SEC",
|
||||
"XAI_ENROLLER_POLL_SEC",
|
||||
"XAI_ENROLLER_AUTH_EXECUTOR",
|
||||
"XAI_ENROLLER_SINK",
|
||||
"XAI_ENROLLER_CPA_BASE_URL",
|
||||
"XAI_ENROLLER_CPA_MANAGEMENT_SECRET",
|
||||
"XAI_ENROLLER_LOCAL_AUTH_DIR",
|
||||
"XAI_ENROLLER_SOURCE_SALT",
|
||||
)
|
||||
explicit = next((key for key in keys if key in message), None)
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
message_keys = {
|
||||
"target must be between 1 and 100": "XAI_ENROLLER_TARGET",
|
||||
"retry attempts must be between 0 and 3": "XAI_ENROLLER_RETRY_ATTEMPTS",
|
||||
"timeout must be positive": "XAI_ENROLLER_TIMEOUT_SEC",
|
||||
"poll interval must be positive": "XAI_ENROLLER_POLL_SEC",
|
||||
"executor must be http or playwright": "XAI_ENROLLER_AUTH_EXECUTOR",
|
||||
"sink must be cpa or local": "XAI_ENROLLER_SINK",
|
||||
"CPA base URL must use HTTPS": "XAI_ENROLLER_CPA_BASE_URL",
|
||||
"CPA base URL and management secret are required": (
|
||||
"XAI_ENROLLER_CPA_BASE_URL/XAI_ENROLLER_CPA_MANAGEMENT_SECRET"
|
||||
),
|
||||
"local auth directory is required": "XAI_ENROLLER_LOCAL_AUTH_DIR",
|
||||
"source salt is required": "XAI_ENROLLER_SOURCE_SALT",
|
||||
}
|
||||
return message_keys.get(message)
|
||||
|
||||
|
||||
def _print_unexpected_service_failure(error, *, log_mode):
|
||||
if log_mode == "debug":
|
||||
detail = f"异常类别 {type(error).__name__}"
|
||||
else:
|
||||
detail = "使用 bash auth-service.sh --debug 查看异常类别"
|
||||
print(f"[✗] 认证服务异常终止 | {detail}", file=sys.stderr)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
load_dotenv(dotenv_path=Path(__file__).resolve().parents[1] / ".env")
|
||||
mode = "user"
|
||||
try:
|
||||
mode = resolve_auth_log_mode(argv)
|
||||
asyncio.run(main_async(log_mode=mode))
|
||||
return 0
|
||||
except AuthServiceConfigurationError as error:
|
||||
key = _known_configuration_key(error) or "认证服务参数"
|
||||
print(
|
||||
f"[✗] 配置错误:{key} | 教程 docs/guides/auth-service.md#配置远端同步",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
except ValueError as error:
|
||||
key = _known_configuration_key(error)
|
||||
if key is not None:
|
||||
print(
|
||||
f"[✗] 配置错误:{key} | 教程 docs/guides/auth-service.md#配置远端同步",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
_print_unexpected_service_failure(error, log_mode=mode)
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
except Exception as error:
|
||||
_print_unexpected_service_failure(error, log_mode=mode)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
106
xai_enroller/sinks.py
Normal file
106
xai_enroller/sinks.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import OAuthCredential, SinkReceipt
|
||||
|
||||
|
||||
class SinkError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CredentialSink(Protocol):
|
||||
async def store(self, credential: OAuthCredential) -> SinkReceipt: ...
|
||||
|
||||
|
||||
def cpa_document(credential: OAuthCredential):
|
||||
return {
|
||||
"type": "xai",
|
||||
"access_token": credential.access_token,
|
||||
"refresh_token": credential.refresh_token,
|
||||
"id_token": credential.id_token,
|
||||
"token_type": credential.token_type,
|
||||
"expires_in": credential.expires_in,
|
||||
"expired": credential.expires_at,
|
||||
"last_refresh": credential.last_refresh,
|
||||
"sub": credential.subject,
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
"token_endpoint": credential.token_endpoint,
|
||||
"auth_kind": "oauth",
|
||||
}
|
||||
|
||||
|
||||
def credential_filename(credential: OAuthCredential, name_secret: bytes):
|
||||
subject = credential.subject or credential.refresh_token
|
||||
digest = hmac.new(name_secret, subject.encode(), hashlib.sha256).hexdigest()[:16]
|
||||
return f"xai-{digest}.json"
|
||||
|
||||
|
||||
class CPAAuthFileSink:
|
||||
def __init__(self, base_url, management_secret, client: httpx.AsyncClient, name_secret=None):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.management_secret = management_secret
|
||||
self.client = client
|
||||
self.name_secret = name_secret or management_secret.encode()
|
||||
|
||||
async def store(self, credential: OAuthCredential):
|
||||
filename = credential_filename(credential, self.name_secret)
|
||||
document = cpa_document(credential)
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/v0/management/auth-files?{urlencode({'name': filename})}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.management_secret}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=document,
|
||||
follow_redirects=False,
|
||||
)
|
||||
if response.status_code // 100 != 2:
|
||||
raise SinkError("CPA upload rejected")
|
||||
return SinkReceipt(filename.removesuffix(".json"))
|
||||
|
||||
|
||||
class LocalAuthFileSink:
|
||||
"""Atomically persist canonical CPA-compatible documents locally."""
|
||||
|
||||
def __init__(self, directory, *, name_secret: bytes):
|
||||
self.directory = Path(directory).expanduser()
|
||||
self.name_secret = name_secret
|
||||
|
||||
async def store(self, credential: OAuthCredential):
|
||||
return await asyncio.to_thread(self._store_sync, credential)
|
||||
|
||||
def _store_sync(self, credential: OAuthCredential):
|
||||
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(self.directory, 0o700)
|
||||
filename = credential_filename(credential, self.name_secret)
|
||||
destination = self.directory / filename
|
||||
payload = json.dumps(cpa_document(credential), ensure_ascii=False, indent=2) + "\n"
|
||||
fd, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{filename}.", suffix=".tmp", dir=self.directory, text=True
|
||||
)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary_name, destination)
|
||||
os.chmod(destination, 0o600)
|
||||
directory_fd = os.open(self.directory, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
if os.path.exists(temporary_name):
|
||||
os.unlink(temporary_name)
|
||||
return SinkReceipt(filename.removesuffix(".json"))
|
||||
61
xai_enroller/sources.py
Normal file
61
xai_enroller/sources.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Protocol
|
||||
|
||||
from .config import require_private_regular_file
|
||||
from .models import SourceRecord
|
||||
|
||||
|
||||
class SourceAdapter(Protocol):
|
||||
def records(self) -> Iterator[SourceRecord]: ...
|
||||
|
||||
|
||||
class FileSourceAdapter:
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
|
||||
def records(self) -> Iterator[SourceRecord]:
|
||||
require_private_regular_file(self.path, require_0600=True)
|
||||
seen = set()
|
||||
with self.path.open("r", encoding="utf-8") as handle:
|
||||
for line_number, line in enumerate(handle, 1):
|
||||
raw = line.rstrip("\r\n")
|
||||
if not raw:
|
||||
continue
|
||||
source_id, separator, token = raw.partition("\t")
|
||||
if not separator or not source_id or not token:
|
||||
raise ValueError(f"invalid source line {line_number}")
|
||||
if source_id in seen:
|
||||
continue
|
||||
seen.add(source_id)
|
||||
yield SourceRecord(source_id, token)
|
||||
|
||||
|
||||
class SQLiteSourceAdapter:
|
||||
def __init__(self, path: Path, salt: bytes):
|
||||
self.path = Path(path)
|
||||
self.salt = bytes(salt)
|
||||
|
||||
def records(self) -> Iterator[SourceRecord]:
|
||||
require_private_regular_file(self.path)
|
||||
uri = f"file:{self.path.absolute()}?mode=ro"
|
||||
connection = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT token FROM accounts "
|
||||
"WHERE status = 'active' AND deleted_at IS NULL "
|
||||
"ORDER BY updated_at DESC"
|
||||
)
|
||||
seen = set()
|
||||
for (token,) in rows:
|
||||
fingerprint = hmac.new(self.salt, token.encode(), hashlib.sha256).digest()
|
||||
source_id = base64.urlsafe_b64encode(fingerprint).rstrip(b"=").decode()
|
||||
if source_id in seen:
|
||||
continue
|
||||
seen.add(source_id)
|
||||
yield SourceRecord(source_id, token)
|
||||
finally:
|
||||
connection.close()
|
||||
Reference in New Issue
Block a user