feat: Grok 注册机 — 注册即入库 CLIProxyAPI (CPA)
- GUI / CLI 批量注册 Grok 账号 - SSO → device-flow OAuth → 本地 auth 目录或远程 Management API - Cloudflare / DuckMail / YYDS 临时邮箱 - Ctrl+C 安全停止,清理阶段不刷 traceback
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
*.py text eol=lf
|
||||
*.md text eol=lf
|
||||
*.json text eol=lf
|
||||
*.txt text eol=lf
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
accounts_*.txt
|
||||
mail_credentials.txt
|
||||
*.txt
|
||||
!requirements.txt
|
||||
|
||||
config.json
|
||||
config.local.json
|
||||
config.local.*
|
||||
*.bak
|
||||
|
||||
# 真实账号凭证 / OAuth token 输出,禁止提交
|
||||
auth_out.json
|
||||
auth_out/
|
||||
auth_merged.json
|
||||
*.auth.json
|
||||
sso_list.txt
|
||||
token.json
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
DrissionPage/
|
||||
autoPortData/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 AaronL725
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,253 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/Git-creat7/grokRegister-cpa)
|
||||
|
||||
批量注册 Grok 账号,注册成功后自动把 OAuth 凭证写入 [CLIProxyAPI (CPA)](https://github.com/router-for-me/CLIProxyAPI):支持本地 auth 目录热加载,也支持 Management API 远程上传。
|
||||
|
||||
<p>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
||||
<img src="https://img.shields.io/badge/Python-3.9%2B-3776AB.svg" alt="Python 3.9+">
|
||||
<img src="https://img.shields.io/badge/Interface-GUI%20%2B%20CLI-success.svg" alt="GUI + CLI">
|
||||
<img src="https://img.shields.io/badge/Output-CLIProxyAPI-orange.svg" alt="CLIProxyAPI">
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> 仅用于自动化流程研究、测试环境验证和个人学习。请遵守目标网站服务条款、当地法律法规与第三方服务限制。
|
||||
|
||||
## 核心流程
|
||||
|
||||
```text
|
||||
打开注册页 → 创建临时邮箱 → 收验证码 → 填资料 / 过人机验证
|
||||
→ 拿到 SSO cookie → device-flow 换 OAuth token
|
||||
→ 本地写入 cpa_auth_dir 和/或 POST 远程 CPA Management API
|
||||
→ CPA 热加载,立即可用
|
||||
```
|
||||
|
||||
## 功能
|
||||
|
||||
- 注册成功后自动入库 CPA(本地目录 / 远程 Management API,可同时开)
|
||||
- GUI + CLI 两种运行方式(CLI 仍会打开浏览器完成注册页)
|
||||
- Chromium/Chrome 自动处理 Turnstile
|
||||
- DuckMail / YYDS / Cloudflare 临时邮箱
|
||||
- 注册后可选开启 NSFW
|
||||
- 页面卡住重试、验证码失败换邮箱、浏览器重启与内存清理
|
||||
- CLI:一次 `Ctrl+C` 安全停止,清理阶段不刷 traceback;再按一次强制中断
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.9+
|
||||
- Google Chrome 或 Chromium
|
||||
- 可用的 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)
|
||||
- 能访问注册页、临时邮箱 API、`auth.x.ai` 的网络(device-flow 换 token 需要)
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Git-creat7/grokRegister-cpa.git
|
||||
cd grokRegister-cpa
|
||||
pip install -r requirements.txt
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
编辑 `config.json` 后运行。
|
||||
|
||||
## 配置
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| --- | --- |
|
||||
| `cpa_auto_add` | 是否开启 CPA 自动入库 |
|
||||
| `cpa_auth_dir` | 本地 CPA auth 目录;写入 `xai-<email>.json`,可留空 |
|
||||
| `cpa_remote_url` | 远程 CPA 地址,如 `http://127.0.0.1:8317` |
|
||||
| `cpa_management_key` | 远程 CPA 管理密钥(`remote-management.secret-key` 明文) |
|
||||
| `email_provider` | `duckmail` / `yyds` / `cloudflare` |
|
||||
| `register_count` | 目标注册数量 |
|
||||
| `proxy` | 代理;device-flow 换 token 也走此代理 |
|
||||
| `enable_nsfw` | 注册后是否尝试开启 NSFW |
|
||||
| `cloudflare_api_base` | Cloudflare 临时邮箱 API 根地址 |
|
||||
| `cloudflare_api_key` | 默认匿名模式留空;admin 模式填 `ADMIN_PASSWORD` |
|
||||
| `cloudflare_auth_mode` | `none` / `bearer` / `x-api-key` / `x-admin-auth` / `query-key` |
|
||||
| `cloudflare_custom_auth` | Worker 全局密码(`PASSWORDS`),注入 `x-custom-auth` |
|
||||
| `cloudflare_path_*` | domains / accounts / token / messages 路径 |
|
||||
| `defaultDomains` | Cloudflare 默认收信域名 |
|
||||
|
||||
### Cloudflare 邮箱(默认匿名)
|
||||
|
||||
```json
|
||||
{
|
||||
"email_provider": "cloudflare",
|
||||
"cloudflare_api_base": "https://你的-worker-api-域名",
|
||||
"cloudflare_api_key": "",
|
||||
"cloudflare_auth_mode": "none",
|
||||
"cloudflare_path_domains": "/api/domains",
|
||||
"cloudflare_path_accounts": "/api/new_address",
|
||||
"cloudflare_path_token": "/api/token",
|
||||
"cloudflare_path_messages": "/api/mails",
|
||||
"defaultDomains": "你的收信域名.com"
|
||||
}
|
||||
```
|
||||
|
||||
匿名创建失败(例如 Turnstile)时可改 admin 创建:
|
||||
|
||||
```json
|
||||
{
|
||||
"cloudflare_api_key": "你的 ADMIN_PASSWORD",
|
||||
"cloudflare_auth_mode": "x-admin-auth",
|
||||
"cloudflare_path_accounts": "/admin/new_address"
|
||||
}
|
||||
```
|
||||
|
||||
调试创建接口:
|
||||
|
||||
```bash
|
||||
python cf_mail_debug.py \
|
||||
--api-base "https://你的-worker-api-域名" \
|
||||
--auth-mode x-admin-auth \
|
||||
--api-key "你的 ADMIN_PASSWORD" \
|
||||
--create-path /admin/new_address \
|
||||
--domain "你的收信域名.com"
|
||||
```
|
||||
|
||||
Worker 若配置了全局 `PASSWORDS`,再加:
|
||||
|
||||
```json
|
||||
{ "cloudflare_custom_auth": "你的全局访问密码" }
|
||||
```
|
||||
|
||||
## CPA 自动入库
|
||||
|
||||
SSO 不是 CPA 凭据。程序会:
|
||||
|
||||
1. 用 SSO 走 device-flow 向 `auth.x.ai` 换 `access_token` / `refresh_token`
|
||||
2. 组装 `type=xai` 扁平 auth(`cli-chat-proxy.grok.com`)
|
||||
3. 本地:`cpa_auth_dir` → `xai-<email>.json`(CPA 热加载)
|
||||
4. 远程:`POST {cpa_remote_url}/v0/management/auth-files?name=...`(需管理密钥)
|
||||
|
||||
### 本地目录
|
||||
|
||||
```json
|
||||
{
|
||||
"cpa_auto_add": true,
|
||||
"cpa_auth_dir": "/path/to/CLIProxyAPI/auths"
|
||||
}
|
||||
```
|
||||
|
||||
跨机器 / WSL 可写挂载路径,例如
|
||||
`//wsl.localhost/Ubuntu/home/you/CLIProxyAPI/auths`。
|
||||
|
||||
### 远程 Management API
|
||||
|
||||
```json
|
||||
{
|
||||
"cpa_auto_add": true,
|
||||
"cpa_auth_dir": "",
|
||||
"cpa_remote_url": "http://127.0.0.1:8317",
|
||||
"cpa_management_key": "你的管理密钥明文"
|
||||
}
|
||||
```
|
||||
|
||||
要求 CPA:`remote-management.allow-remote` 按访问方式配置;密钥为配置里的明文(启动后配置文件可能被写成 bcrypt,上传仍用明文)。
|
||||
|
||||
本地与远程可同时开启。日志前缀:`[CPA]`。
|
||||
|
||||
### 独立转换
|
||||
|
||||
已有 SSO 时可脱离注册流程:
|
||||
|
||||
```bash
|
||||
# 写本地目录
|
||||
python sso_to_auth_json.py --sso sso_list.txt --cpa-auth-dir /path/to/auths
|
||||
|
||||
# 上传远程 CPA
|
||||
python sso_to_auth_json.py --sso sso_list.txt \
|
||||
--cpa-remote-url http://127.0.0.1:8317 \
|
||||
--cpa-management-key '你的管理密钥'
|
||||
|
||||
# 单个 cookie + 代理
|
||||
python sso_to_auth_json.py --sso-cookie 'eyJ...' \
|
||||
--cpa-auth-dir ./auths \
|
||||
--proxy http://127.0.0.1:7890
|
||||
```
|
||||
|
||||
`sso_list.txt`:一行一个 SSO,或 `邮箱----密码----sso`。
|
||||
|
||||
## 运行
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
python grok_register_ttk.py cli
|
||||
```
|
||||
|
||||
提示后输入 `start`。
|
||||
`Ctrl+C` 一次:当前账号收尾后停止;清理浏览器时不会因二次中断刷 traceback。再按一次强制退出。
|
||||
|
||||
### GUI
|
||||
|
||||
```bash
|
||||
python grok_register_ttk.py
|
||||
```
|
||||
|
||||
可在界面里改 CPA 开关、auth 目录、远程地址与管理密钥。
|
||||
|
||||
## 输出文件
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `accounts_*.txt` | 邮箱、密码、SSO |
|
||||
| `mail_credentials.txt` | 临时邮箱凭证 |
|
||||
|
||||
均含敏感信息,已在 `.gitignore` 中忽略。`config.json` 也不提交,请用 `config.example.json` 复制。
|
||||
|
||||
## 稳定性
|
||||
|
||||
- 每账号结束后重启浏览器
|
||||
- 每成功 5 个账号做一次内存清理
|
||||
- 邮箱提交后确认页面前进,避免空等验证码
|
||||
- 未收到验证码时换邮箱重试
|
||||
- 最终页卡住时重试当前账号
|
||||
|
||||
## 常见问题
|
||||
|
||||
**CPA 没出现新账号**
|
||||
检查 `cpa_auto_add`、`cpa_auth_dir` 或 `cpa_remote_url` + `cpa_management_key`;看 `[CPA]` 日志是否换 token / 上传成功;本机/服务器能否访问 `auth.x.ai`。
|
||||
|
||||
**远程上传失败**
|
||||
确认 CPA 管理 API 已启用、密钥明文正确;远程访问需 `allow-remote: true`。可用:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <管理密钥>" \
|
||||
http://127.0.0.1:8317/v0/management/auth-files
|
||||
```
|
||||
|
||||
**CLI 为什么还开浏览器**
|
||||
CLI 只是不启动 Tk;注册页、Turnstile、SSO 仍依赖真实浏览器。
|
||||
|
||||
**NSFW 失败**
|
||||
常见为 Cloudflare 拦截。账号仍会保存并入库 CPA。
|
||||
|
||||
**国内服务器调模型超时**
|
||||
入库成功只说明凭证到了 CPA;调用上游 `cli-chat-proxy.grok.com` 还需服务器出网可达(或配置 CPA `proxy-url`)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── grok_register_ttk.py # 主程序(GUI / CLI + CPA 入库)
|
||||
├── sso_to_auth_json.py # SSO → CPA 转换(可独立运行)
|
||||
├── cf_mail_debug.py # Cloudflare 邮箱调试
|
||||
├── config.example.json
|
||||
├── requirements.txt
|
||||
├── tests/
|
||||
└── assets/banner.png
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Thanks to [linux.do](https://linux.do) and [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
Executable
+240
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from curl_cffi import requests
|
||||
|
||||
|
||||
def extract_code(text: str, subject: str = "") -> Optional[str]:
|
||||
if subject:
|
||||
m = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", subject, re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", text, re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
for p in [
|
||||
r"verification\s+code[:\s]+(\d{4,8})",
|
||||
r"your\s+code[:\s]+(\d{4,8})",
|
||||
r"confirm(?:ation)?\s+code[:\s]+(\d{4,8})",
|
||||
]:
|
||||
m = re.search(p, text, re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def json_or_text(resp: requests.Response) -> Tuple[Optional[Dict[str, Any]], str]:
|
||||
try:
|
||||
data = resp.json()
|
||||
return data, ""
|
||||
except Exception:
|
||||
return None, (resp.text or "")[:400]
|
||||
|
||||
|
||||
def generate_username(length: int = 10) -> str:
|
||||
"""生成 cloudflare_temp_email admin API 需要的随机邮箱名称。"""
|
||||
chars = string.ascii_lowercase + string.digits
|
||||
return "".join(secrets.choice(chars) for _ in range(length))
|
||||
|
||||
|
||||
def normalize_path(path: str, default_path: str) -> str:
|
||||
"""标准化 API 路径,避免漏写开头斜杠。"""
|
||||
raw = (path or default_path).strip() or default_path
|
||||
return raw if raw.startswith("/") else f"/{raw}"
|
||||
|
||||
|
||||
def build_auth_headers(auth_mode: str, api_key: str, content_type: bool = False) -> Dict[str, str]:
|
||||
"""按调试参数构造 Cloudflare 临时邮箱接口鉴权请求头。"""
|
||||
headers = {"Content-Type": "application/json"} if content_type else {}
|
||||
key = (api_key or "").strip()
|
||||
mode = (auth_mode or "none").strip().lower()
|
||||
if not key:
|
||||
return headers
|
||||
if mode == "x-admin-auth":
|
||||
headers["x-admin-auth"] = key
|
||||
elif mode == "x-api-key":
|
||||
headers["X-API-Key"] = key
|
||||
elif mode == "bearer":
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
return headers
|
||||
|
||||
|
||||
def create_address(
|
||||
api_base: str,
|
||||
auth_mode: str = "none",
|
||||
api_key: str = "",
|
||||
create_path: str = "/api/new_address",
|
||||
domain: str = "",
|
||||
name: str = "",
|
||||
) -> Tuple[str, str]:
|
||||
"""创建 Cloudflare 临时邮箱地址,支持匿名 API 和 admin API。"""
|
||||
path = normalize_path(create_path, "/api/new_address")
|
||||
is_admin_create = path.rstrip("/").lower() == "/admin/new_address"
|
||||
if is_admin_create:
|
||||
payload: Dict[str, Any] = {
|
||||
"name": name.strip() if name.strip() else generate_username(),
|
||||
"enablePrefix": True,
|
||||
}
|
||||
if domain.strip():
|
||||
payload["domain"] = domain.strip()
|
||||
headers = build_auth_headers(auth_mode, api_key, content_type=True)
|
||||
else:
|
||||
payload = {}
|
||||
if domain.strip():
|
||||
payload["domain"] = domain.strip()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
resp = requests.post(
|
||||
f"{api_base.rstrip('/')}{path}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=20,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data, raw = json_or_text(resp)
|
||||
if not data:
|
||||
raise RuntimeError(f"{path} 非JSON: {raw}")
|
||||
address = str(data.get("address", "")).strip()
|
||||
jwt = str(data.get("jwt", "")).strip()
|
||||
if not address or not jwt:
|
||||
raise RuntimeError(f"{path} 缺少 address/jwt: {data}")
|
||||
return address, jwt
|
||||
|
||||
|
||||
def fetch_box(api_base: str, jwt: str, path: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
resp = requests.get(
|
||||
f"{api_base.rstrip('/')}{path}",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
timeout=20,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
return []
|
||||
data, _ = json_or_text(resp)
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
if isinstance(data.get("results"), list):
|
||||
return data["results"]
|
||||
if isinstance(data.get("data"), list):
|
||||
return data["data"]
|
||||
if isinstance(data.get("messages"), list):
|
||||
return data["messages"]
|
||||
return []
|
||||
|
||||
|
||||
def probe_all_boxes(api_base: str, jwt: str) -> List[Tuple[str, List[Dict[str, Any]]]]:
|
||||
probes = [
|
||||
("/api/mails", {"limit": 20, "offset": 0}),
|
||||
("/api/sendbox", {"limit": 20, "offset": 0}),
|
||||
("/api/mails", {"limit": 20, "offset": 0, "box": "trash"}),
|
||||
("/api/mails", {"limit": 20, "offset": 0, "folder": "trash"}),
|
||||
("/api/mails", {"limit": 20, "offset": 0, "deleted": "1"}),
|
||||
("/api/mails", {"limit": 20, "offset": 0, "status": "deleted"}),
|
||||
]
|
||||
out: List[Tuple[str, List[Dict[str, Any]]]] = []
|
||||
for path, params in probes:
|
||||
mails = fetch_box(api_base, jwt, path, params)
|
||||
out.append((f"{path}?{params}", mails))
|
||||
return out
|
||||
|
||||
|
||||
def get_detail(api_base: str, jwt: str, mail_id: Any) -> Dict[str, Any]:
|
||||
for url in [
|
||||
f"{api_base.rstrip('/')}/api/mail/{mail_id}",
|
||||
f"{api_base.rstrip('/')}/api/mails/{mail_id}",
|
||||
]:
|
||||
try:
|
||||
resp = requests.get(url, headers={"Authorization": f"Bearer {jwt}"}, timeout=20)
|
||||
if resp.status_code >= 400:
|
||||
continue
|
||||
data, _ = json_or_text(resp)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def flatten_mail_text(item: Dict[str, Any], detail: Dict[str, Any]) -> Tuple[str, str]:
|
||||
subject = str(item.get("subject") or detail.get("subject") or "")
|
||||
parts: List[str] = []
|
||||
for src in (item, detail):
|
||||
for k in ("text", "raw", "content", "intro", "body", "snippet"):
|
||||
v = src.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
parts.append(v)
|
||||
html_val = src.get("html")
|
||||
if isinstance(html_val, str):
|
||||
html_val = [html_val]
|
||||
if isinstance(html_val, list):
|
||||
for h in html_val:
|
||||
if isinstance(h, str):
|
||||
parts.append(re.sub(r"<[^>]+>", " ", h))
|
||||
return subject, "\n".join(parts)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--api-base", required=True)
|
||||
ap.add_argument("--address", default="")
|
||||
ap.add_argument("--credential", default="")
|
||||
ap.add_argument("--auth-mode", default="none", choices=["none", "bearer", "x-api-key", "x-admin-auth"])
|
||||
ap.add_argument("--api-key", default="")
|
||||
ap.add_argument("--create-path", default="/api/new_address")
|
||||
ap.add_argument("--domain", default="")
|
||||
ap.add_argument("--name", default="")
|
||||
ap.add_argument("--timeout", type=int, default=180)
|
||||
ap.add_argument("--interval", type=int, default=3)
|
||||
args = ap.parse_args()
|
||||
|
||||
address = args.address.strip()
|
||||
credential = args.credential.strip()
|
||||
if not credential:
|
||||
address, credential = create_address(
|
||||
args.api_base,
|
||||
auth_mode=args.auth_mode,
|
||||
api_key=args.api_key,
|
||||
create_path=args.create_path,
|
||||
domain=args.domain,
|
||||
name=args.name,
|
||||
)
|
||||
print(f"[NEW] address={address}")
|
||||
print(f"[NEW] credential(jwt)={credential}")
|
||||
else:
|
||||
print(f"[USE] address={address or '(unknown, from credential)'}")
|
||||
|
||||
deadline = time.time() + max(args.timeout, 1)
|
||||
seen_ids = set()
|
||||
while time.time() < deadline:
|
||||
boxes = probe_all_boxes(args.api_base, credential)
|
||||
total = 0
|
||||
for name, mails in boxes:
|
||||
if mails:
|
||||
print(f"[BOX] {name} -> {len(mails)}")
|
||||
total += len(mails)
|
||||
for m in mails:
|
||||
mail_id = m.get("id") or m.get("mail_id")
|
||||
if not mail_id or mail_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(mail_id)
|
||||
detail = get_detail(args.api_base, credential, mail_id)
|
||||
subj, text = flatten_mail_text(m, detail)
|
||||
code = extract_code(text, subj)
|
||||
print(f"[MAIL] id={mail_id} subject={subj!r} code={code!r}")
|
||||
if code:
|
||||
print(f"[FOUND] {code}")
|
||||
return
|
||||
if total == 0:
|
||||
print("[INFO] no mails yet")
|
||||
time.sleep(max(args.interval, 1))
|
||||
print("[TIMEOUT] no code found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"duckmail_api_key": "",
|
||||
"cloudflare_api_base": "https://temp-mail.example.com",
|
||||
"cloudflare_api_key": "",
|
||||
"cloudflare_auth_mode": "none",
|
||||
"cloudflare_custom_auth": "",
|
||||
"cloudflare_path_domains": "/api/domains",
|
||||
"cloudflare_path_accounts": "/api/new_address",
|
||||
"cloudflare_path_token": "/api/token",
|
||||
"cloudflare_path_messages": "/api/mails",
|
||||
"proxy": "",
|
||||
"enable_nsfw": true,
|
||||
"register_count": 1,
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
|
||||
"cpa_auto_add": false,
|
||||
"cpa_auth_dir": "",
|
||||
"cpa_remote_url": "",
|
||||
"cpa_management_key": "",
|
||||
"defaultDomains": "example.com",
|
||||
"email_provider": "cloudflare",
|
||||
"yyds_api_key": "",
|
||||
"yyds_jwt": ""
|
||||
}
|
||||
Executable
+3165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
certifi==2026.4.22
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.7
|
||||
cssselect==1.3.0
|
||||
curl_cffi==0.13.0
|
||||
DataRecorder==3.6.2
|
||||
DownloadKit==2.0.7
|
||||
DrissionPage==4.1.1.2
|
||||
filelock==3.19.1
|
||||
idna==3.13
|
||||
lxml==6.1.0
|
||||
psutil==7.2.2
|
||||
pycparser==2.23
|
||||
requests==2.32.5
|
||||
requests-file==3.0.1
|
||||
tldextract==5.3.0
|
||||
urllib3==2.6.3
|
||||
websocket-client==1.9.0
|
||||
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SSO cookie → ~/.grok/auth.json 格式(纯 HTTP Device Flow)
|
||||
|
||||
用法:
|
||||
# 单个 / 批量 SSO,写出多个独立 auth 文件(每个可直接 cp 到 ~/.grok/auth.json)
|
||||
python3 sso_to_auth_json.py --sso sso_list.txt --out-dir ./auth_out
|
||||
|
||||
# 合并到一个 json(key 带 user_id 后缀,避免覆盖)
|
||||
python3 sso_to_auth_json.py --sso sso_list.txt --out auth_merged.json --merge
|
||||
|
||||
# 单行 sso
|
||||
python3 sso_to_auth_json.py --sso-cookie 'eyJ...' --out ~/.grok/auth.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from curl_cffi import requests
|
||||
|
||||
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
OIDC_ISSUER = "https://auth.x.ai"
|
||||
AUTH_KEY = f"{OIDC_ISSUER}::{CLIENT_ID}"
|
||||
SCOPES = (
|
||||
"openid profile email offline_access grok-cli:access "
|
||||
"api:access conversations:read conversations:write"
|
||||
)
|
||||
|
||||
# --- CLIProxyAPI (CPA) 扁平格式常量 ------------------------------------------
|
||||
# CPA 的 internal/auth/xai/token.go TokenStorage 读的是扁平字段。
|
||||
# Build/CLI token(scope 含 grok-cli:access)必须走 cli-chat-proxy.grok.com,
|
||||
# 不能用默认 api.x.ai/v1(那是计费通道,会 402)。
|
||||
CPA_TOKEN_ENDPOINT = f"{OIDC_ISSUER}/oauth2/token"
|
||||
CPA_GROK_BASE_URL = "https://cli-chat-proxy.grok.com/v1"
|
||||
CPA_GROK_HEADERS = {
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-grok-client-identifier": "grok-shell",
|
||||
}
|
||||
|
||||
|
||||
def b64url_decode(seg: str) -> bytes:
|
||||
seg += "=" * (-len(seg) % 4)
|
||||
return base64.urlsafe_b64decode(seg)
|
||||
|
||||
|
||||
def decode_jwt_payload(token: str) -> dict:
|
||||
try:
|
||||
return json.loads(b64url_decode(token.split(".")[1]))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def rfc3339_ns(ts: float | None = None) -> str:
|
||||
"""2026-07-10T01:00:00.000000000Z"""
|
||||
if ts is None:
|
||||
ts = time.time()
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S") + ".000000000Z"
|
||||
|
||||
|
||||
def _urlopen(req, proxy: str = "", timeout: int = 15):
|
||||
"""urllib 请求,proxy 非空时走代理。"""
|
||||
if proxy:
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({"http": proxy, "https": proxy})
|
||||
)
|
||||
return opener.open(req, timeout=timeout)
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
|
||||
def request_device_code(proxy: str = "", log=print) -> dict | None:
|
||||
data = urllib.parse.urlencode({"client_id": CLIENT_ID, "scope": SCOPES}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{OIDC_ISSUER}/oauth2/device/code",
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
try:
|
||||
with _urlopen(req, proxy=proxy, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
log(f" ❌ device/code HTTP {e.code}: {e.read().decode()[:200]}")
|
||||
return None
|
||||
|
||||
|
||||
def poll_token(device_code: str, interval: int, expires_in: int, timeout: int = 60, proxy: str = "", log=print) -> dict | None:
|
||||
deadline = time.time() + min(expires_in, timeout)
|
||||
while time.time() < deadline:
|
||||
time.sleep(interval)
|
||||
data = urllib.parse.urlencode(
|
||||
{
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"device_code": device_code,
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{OIDC_ISSUER}/oauth2/token",
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
try:
|
||||
with _urlopen(req, proxy=proxy, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
err = json.loads(e.read())
|
||||
error = err.get("error", "")
|
||||
if error == "authorization_pending":
|
||||
continue
|
||||
if error == "slow_down":
|
||||
interval += 5
|
||||
continue
|
||||
log(f" ❌ token: {error}")
|
||||
return None
|
||||
log(" ❌ 轮询超时")
|
||||
return None
|
||||
|
||||
|
||||
def sso_to_token(sso_cookie: str, proxy: str = "", log=print) -> dict | None:
|
||||
"""SSO cookie → token dict (access/refresh/expires_in)。proxy 非空时全程走代理。"""
|
||||
proxies = {"http": proxy, "https": proxy} if proxy else None
|
||||
s = requests.Session()
|
||||
if proxies:
|
||||
s.proxies = proxies
|
||||
s.cookies.set("sso", sso_cookie, domain=".x.ai")
|
||||
|
||||
try:
|
||||
r = s.get("https://accounts.x.ai/", impersonate="chrome", timeout=15)
|
||||
except Exception as e:
|
||||
log(f" ❌ 网络错误: {e}")
|
||||
return None
|
||||
if "sign-in" in r.url or "sign-up" in r.url:
|
||||
log(" ❌ sso 无效")
|
||||
return None
|
||||
log(" ✅ sso 有效")
|
||||
|
||||
log(" 🔑 Device Flow...")
|
||||
dc = request_device_code(proxy=proxy, log=log)
|
||||
if not dc:
|
||||
return None
|
||||
log(f" 📋 user_code: {dc.get('user_code')}")
|
||||
|
||||
try:
|
||||
s.get(dc["verification_uri_complete"], impersonate="chrome", timeout=15)
|
||||
r = s.post(
|
||||
f"{OIDC_ISSUER}/oauth2/device/verify",
|
||||
data={"user_code": dc["user_code"]},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
impersonate="chrome",
|
||||
timeout=15,
|
||||
allow_redirects=True,
|
||||
)
|
||||
if "consent" not in r.url:
|
||||
log(f" ❌ verify 失败: {r.url}")
|
||||
return None
|
||||
except Exception as e:
|
||||
log(f" ❌ verify 异常: {e}")
|
||||
return None
|
||||
|
||||
try:
|
||||
r = s.post(
|
||||
f"{OIDC_ISSUER}/oauth2/device/approve",
|
||||
data={
|
||||
"user_code": dc["user_code"],
|
||||
"action": "allow",
|
||||
"principal_type": "User",
|
||||
"principal_id": "",
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
impersonate="chrome",
|
||||
timeout=15,
|
||||
allow_redirects=True,
|
||||
)
|
||||
if "done" not in r.url:
|
||||
log(f" ❌ approve 失败: {r.url}")
|
||||
return None
|
||||
log(" ✅ 授权确认")
|
||||
except Exception as e:
|
||||
log(f" ❌ approve 异常: {e}")
|
||||
return None
|
||||
|
||||
token = poll_token(
|
||||
dc["device_code"],
|
||||
dc.get("interval", 5),
|
||||
dc.get("expires_in", 1800),
|
||||
proxy=proxy,
|
||||
log=log,
|
||||
)
|
||||
if not token:
|
||||
return None
|
||||
log(
|
||||
f" ✅ access_token (expires_in={token.get('expires_in')}s)"
|
||||
+ (" + refresh_token" if token.get("refresh_token") else "")
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def token_to_auth_entry(token: dict, email: str = "") -> tuple[str, dict]:
|
||||
"""
|
||||
返回 (top_level_key, entry)
|
||||
top_level_key 固定为 issuer::client_id(与 ~/.grok/auth.json 一致)
|
||||
"""
|
||||
access = token.get("access_token") or token.get("key") or ""
|
||||
refresh = token.get("refresh_token") or ""
|
||||
payload = decode_jwt_payload(access)
|
||||
|
||||
user_id = payload.get("sub") or payload.get("principal_id") or ""
|
||||
principal_id = payload.get("principal_id") or user_id
|
||||
principal_type = payload.get("principal_type") or "User"
|
||||
|
||||
expires_in = int(token.get("expires_in") or 21600)
|
||||
# 优先用 JWT exp
|
||||
if "exp" in payload:
|
||||
expires_at = rfc3339_ns(float(payload["exp"]))
|
||||
else:
|
||||
expires_at = rfc3339_ns(time.time() + expires_in)
|
||||
|
||||
iat = payload.get("iat")
|
||||
create_time = rfc3339_ns(float(iat) if iat else time.time())
|
||||
|
||||
entry = {
|
||||
"key": access,
|
||||
"auth_mode": "oidc",
|
||||
"create_time": create_time,
|
||||
"user_id": user_id,
|
||||
"email": email or "",
|
||||
"principal_type": principal_type,
|
||||
"principal_id": principal_id,
|
||||
"refresh_token": refresh,
|
||||
"expires_at": expires_at,
|
||||
"oidc_issuer": OIDC_ISSUER,
|
||||
"oidc_client_id": CLIENT_ID,
|
||||
}
|
||||
return AUTH_KEY, entry
|
||||
|
||||
|
||||
def _iso_utc_from_unix(ts) -> str:
|
||||
"""unix 秒 → CPA 认的 RFC3339(秒级,带 Z)。"""
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _safe_email_for_filename(email: str) -> str:
|
||||
safe = "".join(ch if ch.isalnum() or ch in "._-@" else "_" for ch in email)
|
||||
return safe or "unknown"
|
||||
|
||||
|
||||
def token_to_cpa_record(token: dict, email: str = "") -> dict:
|
||||
"""token dict → CLIProxyAPI 扁平 xai auth 记录。
|
||||
|
||||
对齐 CPA internal/auth/xai/token.go 的 TokenStorage 字段,以及
|
||||
grok-build-auth build_cliproxyapi_auth_record 的输出。
|
||||
"""
|
||||
access = token.get("access_token") or token.get("key") or ""
|
||||
refresh = token.get("refresh_token") or ""
|
||||
id_token = token.get("id_token") or ""
|
||||
payload = decode_jwt_payload(access)
|
||||
id_payload = decode_jwt_payload(id_token) if id_token else {}
|
||||
|
||||
if not email:
|
||||
email = id_payload.get("email") or payload.get("email") or ""
|
||||
sub = payload.get("sub") or id_payload.get("sub") or ""
|
||||
|
||||
# expired: 优先 access token 的 exp,其次 expires_in 推算
|
||||
expired = ""
|
||||
if "exp" in payload:
|
||||
expired = _iso_utc_from_unix(payload["exp"])
|
||||
elif token.get("expires_in") is not None:
|
||||
try:
|
||||
expired = _iso_utc_from_unix(int(time.time()) + int(token["expires_in"]))
|
||||
except Exception:
|
||||
expired = ""
|
||||
|
||||
return {
|
||||
"type": "xai",
|
||||
"auth_kind": "oauth",
|
||||
"email": email or "",
|
||||
"sub": sub,
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
"id_token": id_token,
|
||||
"token_type": token.get("token_type", "Bearer"),
|
||||
"expires_in": token.get("expires_in", None),
|
||||
"expired": expired,
|
||||
"last_refresh": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"redirect_uri": "",
|
||||
"token_endpoint": CPA_TOKEN_ENDPOINT,
|
||||
"base_url": CPA_GROK_BASE_URL,
|
||||
"disabled": False,
|
||||
"headers": dict(CPA_GROK_HEADERS),
|
||||
}
|
||||
|
||||
|
||||
def cpa_auth_filename(record: dict) -> str:
|
||||
"""生成 CPA auth 文件名:xai-<email>.json。"""
|
||||
ident = str(record.get("email") or "").strip() or str(record.get("sub") or "").strip()
|
||||
safe = _safe_email_for_filename(ident)
|
||||
# 避免 email 本地部分已是 xai 时出现 "xai-xai..."
|
||||
fname = safe if safe.lower().startswith("xai") else f"xai-{safe}"
|
||||
return f"{fname}.json"
|
||||
|
||||
|
||||
def write_cpa_auth(auth_dir: Path, record: dict) -> Path:
|
||||
"""写出 CPA 可热加载的 xai-<email>.json(原子替换)。
|
||||
|
||||
无 email 时用 sub(user_id) 命名,避免多个无 email 账号写成同一个
|
||||
xai-unknown.json 互相覆盖。
|
||||
"""
|
||||
auth_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = auth_dir / cpa_auth_filename(record)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
return path
|
||||
|
||||
|
||||
def upload_cpa_auth_remote(
|
||||
base_url: str,
|
||||
management_key: str,
|
||||
record: dict,
|
||||
timeout: int = 30,
|
||||
) -> str:
|
||||
"""通过 CPA Management API 上传 auth 文件到远程实例。
|
||||
|
||||
POST /v0/management/auth-files?name=<file.json>
|
||||
Header: Authorization: Bearer <management_key>
|
||||
Body: raw JSON auth record
|
||||
"""
|
||||
import requests
|
||||
|
||||
base = str(base_url or "").strip().rstrip("/")
|
||||
key = str(management_key or "").strip()
|
||||
if not base:
|
||||
raise ValueError("cpa_remote_url 为空")
|
||||
if not key:
|
||||
raise ValueError("cpa_management_key 为空")
|
||||
|
||||
name = cpa_auth_filename(record)
|
||||
url = f"{base}/v0/management/auth-files"
|
||||
resp = requests.post(
|
||||
url,
|
||||
params={"name": name},
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data=json.dumps(record, ensure_ascii=False).encode("utf-8"),
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
body = (resp.text or "").strip()
|
||||
if len(body) > 300:
|
||||
body = body[:300] + "..."
|
||||
raise RuntimeError(f"远程上传失败 HTTP {resp.status_code}: {body or resp.reason}")
|
||||
return name
|
||||
|
||||
|
||||
def write_auth_json(path: Path, auth_key: str, entry: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {auth_key: entry}
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def merge_auth_json(path: Path, auth_key: str, entry: dict, unique: bool = True) -> None:
|
||||
"""
|
||||
合并写入。unique=True 时 key 变成 issuer::client_id::user_id,避免多账号互相覆盖。
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing: dict = {}
|
||||
if path.exists():
|
||||
try:
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
existing = {}
|
||||
key = auth_key
|
||||
if unique and entry.get("user_id"):
|
||||
key = f"{auth_key}::{entry['user_id']}"
|
||||
existing[key] = entry
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def load_sso_list(path: str | None, single: str | None) -> list[str]:
|
||||
if single:
|
||||
return [single.strip()]
|
||||
if not path:
|
||||
return []
|
||||
out = []
|
||||
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# 兼容 邮箱----密码----sso
|
||||
if "----" in line:
|
||||
parts = line.split("----")
|
||||
line = parts[-1].strip()
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="SSO cookie → grok auth.json (纯 HTTP)")
|
||||
ap.add_argument("--sso", metavar="FILE", help="sso 列表文件(一行一个 JWT,或 邮箱----密码----sso)")
|
||||
ap.add_argument("--sso-cookie", metavar="JWT", help="单个 sso cookie")
|
||||
ap.add_argument("--out", default=None, help="输出 auth.json 路径(单账号或 --merge)")
|
||||
ap.add_argument(
|
||||
"--out-dir",
|
||||
default=None,
|
||||
help="批量时每个账号写一个 {user_id}.json(可直接 cp 到 ~/.grok/auth.json)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--merge",
|
||||
action="store_true",
|
||||
help="合并到 --out,key 用 issuer::client_id::user_id",
|
||||
)
|
||||
ap.add_argument("--delay", type=int, default=0, help="每个间隔秒数")
|
||||
ap.add_argument("--email", default="", help="写入 entry.email(可选)")
|
||||
ap.add_argument(
|
||||
"--cpa-auth-dir",
|
||||
default=None,
|
||||
help="额外写出 CLIProxyAPI 扁平格式 xai-<email>.json 到该目录(CPA 热加载,可直接指向 CPA 挂载目录)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--cpa-remote-url",
|
||||
default=None,
|
||||
help="远程 CPA 地址,如 http://IP:8317;配合 --cpa-management-key 通过 Management API 上传",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--cpa-management-key",
|
||||
default=None,
|
||||
help="远程 CPA 管理密钥(remote-management.secret-key 明文)",
|
||||
)
|
||||
ap.add_argument("--proxy", default="", help="device-flow 走代理,如 http://127.0.0.1:7890")
|
||||
args = ap.parse_args()
|
||||
|
||||
cookies = load_sso_list(args.sso, args.sso_cookie)
|
||||
if not cookies:
|
||||
ap.error("需要 --sso 或 --sso-cookie")
|
||||
|
||||
if args.cpa_remote_url and not args.cpa_management_key:
|
||||
ap.error("使用 --cpa-remote-url 时必须同时提供 --cpa-management-key")
|
||||
if args.cpa_management_key and not args.cpa_remote_url:
|
||||
ap.error("使用 --cpa-management-key 时必须同时提供 --cpa-remote-url")
|
||||
|
||||
if len(cookies) > 1 and not args.out_dir and not args.merge:
|
||||
# 默认批量写目录
|
||||
args.out_dir = args.out_dir or "./auth_out"
|
||||
print(f"批量模式默认 --out-dir {args.out_dir}")
|
||||
|
||||
# 只指定 CPA 目标时不再默认写官方 ~/.grok/auth.json
|
||||
if (
|
||||
args.out is None
|
||||
and args.out_dir is None
|
||||
and not args.cpa_auth_dir
|
||||
and not args.cpa_remote_url
|
||||
and len(cookies) == 1
|
||||
):
|
||||
args.out = str(Path.home() / ".grok" / "auth.json")
|
||||
|
||||
print(f"🚀 SSO → auth.json: {len(cookies)} 个, delay={args.delay}s")
|
||||
ok = 0
|
||||
fail = 0
|
||||
|
||||
for i, sso in enumerate(cookies, 1):
|
||||
print(f"\n{'=' * 60}\n[{i}/{len(cookies)}] ...\n{'=' * 60}")
|
||||
try:
|
||||
token = sso_to_token(sso, proxy=args.proxy)
|
||||
if not token:
|
||||
fail += 1
|
||||
print(f" ❌ [{i}] 失败")
|
||||
continue
|
||||
key, entry = token_to_auth_entry(token, email=args.email)
|
||||
uid = entry.get("user_id") or secrets.token_hex(4)
|
||||
|
||||
if args.out_dir:
|
||||
p = Path(args.out_dir) / f"{uid}.json"
|
||||
write_auth_json(p, key, entry)
|
||||
print(f" 💾 {p}")
|
||||
if args.out:
|
||||
if args.merge or len(cookies) > 1:
|
||||
merge_auth_json(Path(args.out), key, entry, unique=True)
|
||||
print(f" 💾 merge → {args.out}")
|
||||
else:
|
||||
write_auth_json(Path(args.out), key, entry)
|
||||
print(f" 💾 {args.out}")
|
||||
|
||||
if args.cpa_auth_dir or args.cpa_remote_url:
|
||||
record = token_to_cpa_record(token, email=args.email)
|
||||
if args.cpa_auth_dir:
|
||||
cp = write_cpa_auth(Path(args.cpa_auth_dir), record)
|
||||
print(f" 💾 CPA 本地 → {cp}")
|
||||
if args.cpa_remote_url:
|
||||
name = upload_cpa_auth_remote(
|
||||
args.cpa_remote_url,
|
||||
args.cpa_management_key,
|
||||
record,
|
||||
)
|
||||
print(f" 💾 CPA 远程 → {args.cpa_remote_url.rstrip('/')}/.../{name}")
|
||||
|
||||
ok += 1
|
||||
print(f" ✅ [{i}] 完成 user_id={uid[:12]}...")
|
||||
except Exception as e:
|
||||
fail += 1
|
||||
print(f" ❌ [{i}] 异常: {e}")
|
||||
|
||||
if args.delay > 0 and i < len(cookies):
|
||||
time.sleep(args.delay)
|
||||
|
||||
print(f"\n{'=' * 60}\n📊 完成: {ok}/{len(cookies)} 成功, {fail} 失败")
|
||||
return 0 if fail == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,155 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import cf_mail_debug
|
||||
import grok_register_ttk as app
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
self.text = ""
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class CloudflareAdminCreateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.original_config = app.config.copy()
|
||||
self.original_cf_domain_index = app._cf_domain_index
|
||||
app._cf_domain_index = 0
|
||||
|
||||
def tearDown(self):
|
||||
app.config = self.original_config
|
||||
app._cf_domain_index = self.original_cf_domain_index
|
||||
|
||||
def test_default_config_keeps_cloudflare_temp_email_new_address(self):
|
||||
app.config = app.DEFAULT_CONFIG.copy()
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured.update(kwargs)
|
||||
return DummyResponse({"address": "anon@example.com", "jwt": "default-jwt"})
|
||||
|
||||
with patch.object(app, "http_post", side_effect=fake_post):
|
||||
address, jwt = app.cloudflare_create_temp_address("https://temp-mail.example.com")
|
||||
|
||||
self.assertEqual(address, "anon@example.com")
|
||||
self.assertEqual(jwt, "default-jwt")
|
||||
self.assertEqual(captured["url"], "https://temp-mail.example.com/api/new_address")
|
||||
self.assertEqual(captured["json"], {})
|
||||
self.assertEqual(captured["headers"], {"Content-Type": "application/json"})
|
||||
|
||||
def test_app_uses_admin_new_address_with_x_admin_auth(self):
|
||||
app.config.update({
|
||||
"cloudflare_api_key": "admin-secret",
|
||||
"cloudflare_auth_mode": "x-admin-auth",
|
||||
"cloudflare_path_accounts": "/admin/new_address",
|
||||
"defaultDomains": "vitassk.com",
|
||||
})
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured.update(kwargs)
|
||||
return DummyResponse({"address": "adminuser@vitassk.com", "jwt": "address-jwt"})
|
||||
|
||||
with patch.object(app, "generate_username", return_value="adminuser"), \
|
||||
patch.object(app, "http_post", side_effect=fake_post):
|
||||
address, jwt = app.cloudflare_create_temp_address("https://temp-mail.ikun.day")
|
||||
|
||||
self.assertEqual(address, "adminuser@vitassk.com")
|
||||
self.assertEqual(jwt, "address-jwt")
|
||||
self.assertEqual(captured["url"], "https://temp-mail.ikun.day/admin/new_address")
|
||||
self.assertEqual(captured["json"], {
|
||||
"name": "adminuser",
|
||||
"domain": "vitassk.com",
|
||||
"enablePrefix": True,
|
||||
})
|
||||
self.assertEqual(captured["headers"]["Content-Type"], "application/json")
|
||||
self.assertEqual(captured["headers"]["x-admin-auth"], "admin-secret")
|
||||
|
||||
def test_app_keeps_anonymous_new_address_with_none_auth(self):
|
||||
app.config.update({
|
||||
"cloudflare_api_key": "",
|
||||
"cloudflare_auth_mode": "none",
|
||||
"cloudflare_custom_auth": "",
|
||||
"cloudflare_path_accounts": "/api/new_address",
|
||||
"defaultDomains": "vitassk.com",
|
||||
})
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured.update(kwargs)
|
||||
return DummyResponse({"address": "anon@vitassk.com", "jwt": "anon-jwt"})
|
||||
|
||||
with patch.object(app, "http_post", side_effect=fake_post):
|
||||
address, jwt = app.cloudflare_create_temp_address("https://temp-mail.ikun.day")
|
||||
|
||||
self.assertEqual(address, "anon@vitassk.com")
|
||||
self.assertEqual(jwt, "anon-jwt")
|
||||
self.assertEqual(captured["url"], "https://temp-mail.ikun.day/api/new_address")
|
||||
self.assertEqual(captured["json"], {"domain": "vitassk.com"})
|
||||
self.assertEqual(captured["headers"], {"Content-Type": "application/json"})
|
||||
|
||||
def test_app_injects_custom_auth_on_anonymous_new_address(self):
|
||||
app.config.update({
|
||||
"cloudflare_api_key": "",
|
||||
"cloudflare_auth_mode": "none",
|
||||
"cloudflare_custom_auth": "global-pass",
|
||||
"cloudflare_path_accounts": "/api/new_address",
|
||||
"defaultDomains": "vitassk.com",
|
||||
})
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured.update(kwargs)
|
||||
return DummyResponse({"address": "anon@vitassk.com", "jwt": "anon-jwt"})
|
||||
|
||||
with patch.object(app, "http_post", side_effect=fake_post):
|
||||
app.cloudflare_create_temp_address("https://temp-mail.ikun.day")
|
||||
|
||||
self.assertEqual(captured["headers"], {
|
||||
"Content-Type": "application/json",
|
||||
"x-custom-auth": "global-pass",
|
||||
})
|
||||
|
||||
def test_debug_tool_can_create_address_through_admin_api(self):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured.update(kwargs)
|
||||
return DummyResponse({"address": "debuguser@vitassk.com", "jwt": "debug-jwt"})
|
||||
|
||||
with patch.object(cf_mail_debug.requests, "post", side_effect=fake_post):
|
||||
address, jwt = cf_mail_debug.create_address(
|
||||
"https://temp-mail.ikun.day",
|
||||
auth_mode="x-admin-auth",
|
||||
api_key="admin-secret",
|
||||
create_path="/admin/new_address",
|
||||
domain="vitassk.com",
|
||||
name="debuguser",
|
||||
)
|
||||
|
||||
self.assertEqual(address, "debuguser@vitassk.com")
|
||||
self.assertEqual(jwt, "debug-jwt")
|
||||
self.assertEqual(captured["url"], "https://temp-mail.ikun.day/admin/new_address")
|
||||
self.assertEqual(captured["json"], {
|
||||
"name": "debuguser",
|
||||
"domain": "vitassk.com",
|
||||
"enablePrefix": True,
|
||||
})
|
||||
self.assertEqual(captured["headers"]["Content-Type"], "application/json")
|
||||
self.assertEqual(captured["headers"]["x-admin-auth"], "admin-secret")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user