Merge branch 'fork/TizenryA/feat/warp-flaresolverr-runtime'
Browse files- .env.example +34 -0
- CHANGELOG.md +8 -0
- README.md +35 -0
- VERSION +1 -1
- api/system.py +32 -5
- config.json +22 -0
- docker-compose.warp.yml +108 -0
- scripts/init_proxy_config.py +186 -0
- scripts/privoxy-warp.conf +13 -0
- services/config.py +133 -1
- services/proxy_service.py +585 -13
- services/register/openai_register.py +126 -13
- test/test_init_proxy_config.py +117 -0
- test/test_proxy_runtime_api.py +181 -0
- test/test_proxy_runtime_config.py +220 -0
- test/test_proxy_service.py +415 -0
- test/test_register_proxy_runtime.py +194 -0
- web/src/app/settings/components/proxy-runtime-card.tsx +359 -0
- web/src/app/settings/page.tsx +2 -0
- web/src/app/settings/store.ts +155 -0
- web/src/lib/api.ts +75 -0
.env.example
CHANGED
|
@@ -46,3 +46,37 @@ STORAGE_BACKEND=json
|
|
| 46 |
|
| 47 |
# Git 仓库中的文件路径(可选,默认: accounts.json)
|
| 48 |
# GIT_FILE_PATH=accounts.json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
# Git 仓库中的文件路径(可选,默认: accounts.json)
|
| 48 |
# GIT_FILE_PATH=accounts.json
|
| 49 |
+
|
| 50 |
+
# ============================================
|
| 51 |
+
# WARP / FlareSolverr 稳定代理配置(可选)
|
| 52 |
+
# ============================================
|
| 53 |
+
|
| 54 |
+
# docker-compose.warp.yml 暴露到宿主机的端口
|
| 55 |
+
# WARP_SOCKS_PORT=40000
|
| 56 |
+
# PRIVOXY_PORT=40080
|
| 57 |
+
# FLARESOLVERR_PORT=8191
|
| 58 |
+
|
| 59 |
+
# FlareSolverr 日志与时区
|
| 60 |
+
# FLARESOLVERR_LOG_LEVEL=info
|
| 61 |
+
# TZ=Asia/Shanghai
|
| 62 |
+
|
| 63 |
+
# Cloudflare WARP+ License Key(可选)
|
| 64 |
+
# WARP_LICENSE_KEY=
|
| 65 |
+
|
| 66 |
+
# 初始化 proxy_runtime 默认值
|
| 67 |
+
# CHATGPT2API_PROXY_RUNTIME_ENABLED=true
|
| 68 |
+
# CHATGPT2API_PROXY_RUNTIME_EGRESS_MODE=single_proxy
|
| 69 |
+
# CHATGPT2API_PROXY_RUNTIME_PROXY_URL=http://privoxy:8118
|
| 70 |
+
# CHATGPT2API_PROXY_RUNTIME_RESOURCE_PROXY_URL=
|
| 71 |
+
# CHATGPT2API_PROXY_RUNTIME_SKIP_SSL_VERIFY=false
|
| 72 |
+
# CHATGPT2API_PROXY_RUNTIME_RESET_STATUS_CODES=403
|
| 73 |
+
|
| 74 |
+
# Cloudflare clearance 刷新配置
|
| 75 |
+
# CHATGPT2API_PROXY_RUNTIME_CLEARANCE_ENABLED=true
|
| 76 |
+
# CHATGPT2API_PROXY_RUNTIME_CLEARANCE_MODE=flaresolverr
|
| 77 |
+
# CHATGPT2API_FLARESOLVERR_URL=http://flaresolverr:8191
|
| 78 |
+
# CHATGPT2API_PROXY_RUNTIME_CLEARANCE_TIMEOUT_SEC=60
|
| 79 |
+
# CHATGPT2API_PROXY_RUNTIME_CLEARANCE_REFRESH_INTERVAL=3600
|
| 80 |
+
# CHATGPT2API_PROXY_RUNTIME_WARM_UP_ON_START=false
|
| 81 |
+
# CHATGPT2API_PROXY_RUNTIME_BROWSER=chrome
|
| 82 |
+
# CHATGPT2API_PROXY_RUNTIME_USER_AGENT=
|
CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
| 2 |
|
| 3 |
## Unreleased
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
## 1.4.1 - 2026-06-03
|
| 6 |
|
| 7 |
+ [新增] 账号刷新改为异步模式,支持前端轮询刷新/重新登录进度。
|
|
|
|
| 2 |
|
| 3 |
## Unreleased
|
| 4 |
|
| 5 |
+
## 1.4.2 - 2026-06-11
|
| 6 |
+
|
| 7 |
+
+ [新增] 稳定代理运行时。
|
| 8 |
+
+ [新增] FlareSolverr 清障。
|
| 9 |
+
+ [新增] WARP Compose 部署。
|
| 10 |
+
+ [新增] 设置页代理面板。
|
| 11 |
+
+ [优化] 注册拦截后重试。
|
| 12 |
+
|
| 13 |
## 1.4.1 - 2026-06-03
|
| 14 |
|
| 15 |
+ [新增] 账号刷新改为异步模式,支持前端轮询刷新/重新登录进度。
|
README.md
CHANGED
|
@@ -15,6 +15,19 @@
|
|
| 15 |
> - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。
|
| 16 |
> - 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
## 快速开始
|
| 19 |
|
| 20 |
已发布镜像支持 `linux/amd64` 与 `linux/arm64`,在 x86 服务器和 Apple Silicon / ARM Linux 设备上都会自动拉取匹配架构的版本。
|
|
@@ -33,6 +46,27 @@ docker compose up -d
|
|
| 33 |
- API 地址:`http://localhost:3000/v1`
|
| 34 |
- 数据目录:`./data`
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
### 本地开发
|
| 37 |
|
| 38 |
启动后端:
|
|
@@ -113,6 +147,7 @@ environment:
|
|
| 113 |
- 定时检查限流账号并自动刷新
|
| 114 |
- 支持密码重新登录恢复异常账号,刷新后可自动重登
|
| 115 |
- 支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理
|
|
|
|
| 116 |
- 支持搜索、筛选、批量刷新、导出、手动编辑和清理账号
|
| 117 |
- 支持四种导入方式:本地 CPA JSON 文件导入、远程 CPA 服务器导入、`sub2api` 服务器导入、`access_token` 导入
|
| 118 |
- 支持在设置页配置 `sub2api` 服务器,筛选并批量导入其中的 OpenAI OAuth 账号
|
|
|
|
| 15 |
> - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。
|
| 16 |
> - 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。
|
| 17 |
|
| 18 |
+
## 本分支修改内容
|
| 19 |
+
|
| 20 |
+
本分支在 1.4.2 基础上补充注册与生图链路稳定性能力,核心改动如下:
|
| 21 |
+
|
| 22 |
+
- 新增默认关闭的稳定代理运行时 `proxy_runtime`。
|
| 23 |
+
- 引入 WARP + Privoxy + FlareSolverr 组合部署。
|
| 24 |
+
- 注册遇到 Cloudflare 拦截后刷新 clearance 并重试。
|
| 25 |
+
- 增加 FlareSolverr clearance 缓存、测试接口与脱敏展示。
|
| 26 |
+
- 后台设置页新增「稳定代理运行时」配置面板。
|
| 27 |
+
- 新增 `docker-compose.warp.yml` 与幂等初始化脚本。
|
| 28 |
+
- 代理优先级:账号代理 > 稳定代理运行时 > 显式代理 > 全局代理。
|
| 29 |
+
- 补充代理运行时、注册重试和初始化脚本单元测试。
|
| 30 |
+
|
| 31 |
## 快速开始
|
| 32 |
|
| 33 |
已发布镜像支持 `linux/amd64` 与 `linux/arm64`,在 x86 服务器和 Apple Silicon / ARM Linux 设备上都会自动拉取匹配架构的版本。
|
|
|
|
| 46 |
- API 地址:`http://localhost:3000/v1`
|
| 47 |
- 数据目录:`./data`
|
| 48 |
|
| 49 |
+
### WARP / FlareSolverr 稳定代理部署
|
| 50 |
+
|
| 51 |
+
如果注册或图片链路经常遇到 Cloudflare 拦截,可以启用附带的 WARP + Privoxy + FlareSolverr 方案:
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
cp .env.example .env
|
| 55 |
+
docker compose -f docker-compose.warp.yml up -d --build
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
该 compose 会启动:
|
| 59 |
+
|
| 60 |
+
- `warp-proxy`:提供 WARP SOCKS5 出口。
|
| 61 |
+
- `privoxy`:把 WARP SOCKS5 转成 HTTP 代理。
|
| 62 |
+
- `flaresolverr`:刷新 Cloudflare clearance。
|
| 63 |
+
- `init-config`:幂等写入 `proxy_runtime` 默认配置。
|
| 64 |
+
- `app`:启动 ChatGPT2API 主服务。
|
| 65 |
+
|
| 66 |
+
默认只让上游 OpenAI / ChatGPT 请求走稳定代理,账号邮箱、CPA 等辅助链路不会被强制接管。账号自身配置的代理优先级最高,其次是稳定代理运行时,再其次是显式代理和旧版全局代理。
|
| 67 |
+
|
| 68 |
+
可在 `.env` 中调整端口和代理运行时参数,也可在后台设置页的「稳定代理运行时」面板手动保存、测试代理和测试 clearance。
|
| 69 |
+
|
| 70 |
### 本地开发
|
| 71 |
|
| 72 |
启动后端:
|
|
|
|
| 147 |
- 定时检查限流账号并自动刷新
|
| 148 |
- 支持密码重新登录恢复异常账号,刷新后可自动重登
|
| 149 |
- 支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理
|
| 150 |
+
- 支持 WARP / FlareSolverr 稳定代理运行时
|
| 151 |
- 支持搜索、筛选、批量刷新、导出、手动编辑和清理账号
|
| 152 |
- 支持四种导入方式:本地 CPA JSON 文件导入、远程 CPA 服务器导入、`sub2api` 服务器导入、`access_token` 导入
|
| 153 |
- 支持在设置页配置 `sub2api` 服务器,筛选并批量导入其中的 OpenAI OAuth 账号
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
1.4.
|
|
|
|
| 1 |
+
1.4.2
|
api/system.py
CHANGED
|
@@ -24,7 +24,7 @@ from services.image_service import (
|
|
| 24 |
from services.image_storage_service import ImageStorageError, image_storage_service
|
| 25 |
from services.image_tags_service import delete_tag, get_all_tags, set_tags
|
| 26 |
from services.log_service import log_service
|
| 27 |
-
from services.proxy_service import test_proxy
|
| 28 |
|
| 29 |
|
| 30 |
class SettingsUpdateRequest(BaseModel):
|
|
@@ -35,6 +35,10 @@ class ProxyTestRequest(BaseModel):
|
|
| 35 |
url: str = ""
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
class ImageDeleteRequest(BaseModel):
|
| 39 |
paths: list[str] = []
|
| 40 |
start_date: str = ""
|
|
@@ -131,10 +135,32 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 131 |
@router.post("/api/proxy/test")
|
| 132 |
async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
|
| 133 |
require_admin(authorization)
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
@router.get("/api/storage/info")
|
| 140 |
async def get_storage_info(authorization: str | None = Header(default=None)):
|
|
@@ -275,6 +301,7 @@ def create_router(app_version: str) -> APIRouter:
|
|
| 275 |
"healthy": healthy,
|
| 276 |
"version": app_version,
|
| 277 |
"storage": {"backend": storage.get_backend_info(), "health": storage_health},
|
|
|
|
| 278 |
"accounts": stats,
|
| 279 |
}
|
| 280 |
if format == "json":
|
|
|
|
| 24 |
from services.image_storage_service import ImageStorageError, image_storage_service
|
| 25 |
from services.image_tags_service import delete_tag, get_all_tags, set_tags
|
| 26 |
from services.log_service import log_service
|
| 27 |
+
from services.proxy_service import proxy_settings, test_clearance, test_proxy
|
| 28 |
|
| 29 |
|
| 30 |
class SettingsUpdateRequest(BaseModel):
|
|
|
|
| 35 |
url: str = ""
|
| 36 |
|
| 37 |
|
| 38 |
+
class ClearanceTestRequest(BaseModel):
|
| 39 |
+
target_url: str = "https://chatgpt.com"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
class ImageDeleteRequest(BaseModel):
|
| 43 |
paths: list[str] = []
|
| 44 |
start_date: str = ""
|
|
|
|
| 135 |
@router.post("/api/proxy/test")
|
| 136 |
async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
|
| 137 |
require_admin(authorization)
|
| 138 |
+
return {"result": await run_in_threadpool(test_proxy, (body.url or "").strip())}
|
| 139 |
+
|
| 140 |
+
@router.get("/api/proxy/runtime")
|
| 141 |
+
async def get_proxy_runtime_endpoint(authorization: str | None = Header(default=None)):
|
| 142 |
+
require_admin(authorization)
|
| 143 |
+
return {
|
| 144 |
+
"runtime": config.get_public_proxy_runtime_settings(),
|
| 145 |
+
"status": proxy_settings.get_runtime_status(),
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
@router.post("/api/proxy/runtime")
|
| 149 |
+
async def save_proxy_runtime_endpoint(body: SettingsUpdateRequest, authorization: str | None = Header(default=None)):
|
| 150 |
+
require_admin(authorization)
|
| 151 |
+
try:
|
| 152 |
+
config.update({"proxy_runtime": body.model_dump(mode="python")})
|
| 153 |
+
except ValueError as exc:
|
| 154 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 155 |
+
return {
|
| 156 |
+
"runtime": config.get_public_proxy_runtime_settings(),
|
| 157 |
+
"status": proxy_settings.get_runtime_status(),
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
@router.post("/api/proxy/clearance/test")
|
| 161 |
+
async def test_proxy_clearance_endpoint(body: ClearanceTestRequest, authorization: str | None = Header(default=None)):
|
| 162 |
+
require_admin(authorization)
|
| 163 |
+
return {"result": await run_in_threadpool(test_clearance, body.target_url)}
|
| 164 |
|
| 165 |
@router.get("/api/storage/info")
|
| 166 |
async def get_storage_info(authorization: str | None = Header(default=None)):
|
|
|
|
| 301 |
"healthy": healthy,
|
| 302 |
"version": app_version,
|
| 303 |
"storage": {"backend": storage.get_backend_info(), "health": storage_health},
|
| 304 |
+
"proxy_runtime": proxy_settings.get_runtime_status(),
|
| 305 |
"accounts": stats,
|
| 306 |
}
|
| 307 |
if format == "json":
|
config.json
CHANGED
|
@@ -12,6 +12,28 @@
|
|
| 12 |
"warning"
|
| 13 |
],
|
| 14 |
"proxy": "",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"base_url": "",
|
| 16 |
"sensitive_words": [],
|
| 17 |
"global_system_prompt": "",
|
|
|
|
| 12 |
"warning"
|
| 13 |
],
|
| 14 |
"proxy": "",
|
| 15 |
+
"proxy_runtime": {
|
| 16 |
+
"enabled": false,
|
| 17 |
+
"egress_mode": "direct",
|
| 18 |
+
"proxy_url": "",
|
| 19 |
+
"resource_proxy_url": "",
|
| 20 |
+
"skip_ssl_verify": false,
|
| 21 |
+
"reset_session_status_codes": [
|
| 22 |
+
403
|
| 23 |
+
],
|
| 24 |
+
"clearance": {
|
| 25 |
+
"enabled": false,
|
| 26 |
+
"mode": "none",
|
| 27 |
+
"cf_cookies": "",
|
| 28 |
+
"cf_clearance": "",
|
| 29 |
+
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
| 30 |
+
"browser": "chrome",
|
| 31 |
+
"flaresolverr_url": "",
|
| 32 |
+
"timeout_sec": 60,
|
| 33 |
+
"refresh_interval": 3600,
|
| 34 |
+
"warm_up_on_start": false
|
| 35 |
+
}
|
| 36 |
+
},
|
| 37 |
"base_url": "",
|
| 38 |
"sensitive_words": [],
|
| 39 |
"global_system_prompt": "",
|
docker-compose.warp.yml
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
init-config:
|
| 3 |
+
image: python:3.13-alpine
|
| 4 |
+
container_name: chatgpt2api-warp-init
|
| 5 |
+
working_dir: /app
|
| 6 |
+
volumes:
|
| 7 |
+
- ./config.json:/app/config.json
|
| 8 |
+
- ./scripts:/app/scripts:ro
|
| 9 |
+
environment:
|
| 10 |
+
CHATGPT2API_CONFIG_FILE: /app/config.json
|
| 11 |
+
CHATGPT2API_PROXY_RUNTIME_ENABLED: ${CHATGPT2API_PROXY_RUNTIME_ENABLED:-true}
|
| 12 |
+
CHATGPT2API_PROXY_RUNTIME_EGRESS_MODE: ${CHATGPT2API_PROXY_RUNTIME_EGRESS_MODE:-single_proxy}
|
| 13 |
+
CHATGPT2API_PROXY_RUNTIME_PROXY_URL: ${CHATGPT2API_PROXY_RUNTIME_PROXY_URL:-http://privoxy:8118}
|
| 14 |
+
CHATGPT2API_PROXY_RUNTIME_RESOURCE_PROXY_URL: ${CHATGPT2API_PROXY_RUNTIME_RESOURCE_PROXY_URL:-}
|
| 15 |
+
CHATGPT2API_PROXY_RUNTIME_SKIP_SSL_VERIFY: ${CHATGPT2API_PROXY_RUNTIME_SKIP_SSL_VERIFY:-false}
|
| 16 |
+
CHATGPT2API_PROXY_RUNTIME_RESET_STATUS_CODES: ${CHATGPT2API_PROXY_RUNTIME_RESET_STATUS_CODES:-403}
|
| 17 |
+
CHATGPT2API_PROXY_RUNTIME_CLEARANCE_ENABLED: ${CHATGPT2API_PROXY_RUNTIME_CLEARANCE_ENABLED:-true}
|
| 18 |
+
CHATGPT2API_PROXY_RUNTIME_CLEARANCE_MODE: ${CHATGPT2API_PROXY_RUNTIME_CLEARANCE_MODE:-flaresolverr}
|
| 19 |
+
CHATGPT2API_PROXY_RUNTIME_CLEARANCE_TIMEOUT_SEC: ${CHATGPT2API_PROXY_RUNTIME_CLEARANCE_TIMEOUT_SEC:-60}
|
| 20 |
+
CHATGPT2API_PROXY_RUNTIME_CLEARANCE_REFRESH_INTERVAL: ${CHATGPT2API_PROXY_RUNTIME_CLEARANCE_REFRESH_INTERVAL:-3600}
|
| 21 |
+
CHATGPT2API_PROXY_RUNTIME_WARM_UP_ON_START: ${CHATGPT2API_PROXY_RUNTIME_WARM_UP_ON_START:-false}
|
| 22 |
+
CHATGPT2API_PROXY_RUNTIME_BROWSER: ${CHATGPT2API_PROXY_RUNTIME_BROWSER:-chrome}
|
| 23 |
+
CHATGPT2API_PROXY_RUNTIME_USER_AGENT: ${CHATGPT2API_PROXY_RUNTIME_USER_AGENT:-}
|
| 24 |
+
CHATGPT2API_FLARESOLVERR_URL: ${CHATGPT2API_FLARESOLVERR_URL:-http://flaresolverr:8191}
|
| 25 |
+
command: ["python3", "/app/scripts/init_proxy_config.py"]
|
| 26 |
+
restart: "no"
|
| 27 |
+
|
| 28 |
+
warp-proxy:
|
| 29 |
+
image: caomingjun/warp:latest
|
| 30 |
+
container_name: chatgpt2api-warp-proxy
|
| 31 |
+
restart: unless-stopped
|
| 32 |
+
environment:
|
| 33 |
+
WARP_SLEEP: "2"
|
| 34 |
+
# 可选:填写 Cloudflare WARP+ License Key
|
| 35 |
+
# WARP_LICENSE_KEY: ${WARP_LICENSE_KEY:-}
|
| 36 |
+
cap_add:
|
| 37 |
+
- NET_ADMIN
|
| 38 |
+
- SYS_MODULE
|
| 39 |
+
sysctls:
|
| 40 |
+
net.ipv6.conf.all.disable_ipv6: 0
|
| 41 |
+
net.ipv4.conf.all.src_valid_mark: 1
|
| 42 |
+
ports:
|
| 43 |
+
- "127.0.0.1:${WARP_SOCKS_PORT:-40000}:1080"
|
| 44 |
+
healthcheck:
|
| 45 |
+
test: ["CMD-SHELL", "python3 -c \"import socket; s=socket.create_connection(('127.0.0.1', 1080), 5); s.close()\""]
|
| 46 |
+
interval: 30s
|
| 47 |
+
timeout: 5s
|
| 48 |
+
retries: 5
|
| 49 |
+
start_period: 30s
|
| 50 |
+
|
| 51 |
+
privoxy:
|
| 52 |
+
image: vimagick/privoxy:latest
|
| 53 |
+
container_name: chatgpt2api-privoxy
|
| 54 |
+
restart: unless-stopped
|
| 55 |
+
depends_on:
|
| 56 |
+
warp-proxy:
|
| 57 |
+
condition: service_started
|
| 58 |
+
volumes:
|
| 59 |
+
- ./scripts/privoxy-warp.conf:/etc/privoxy/config:ro
|
| 60 |
+
ports:
|
| 61 |
+
- "127.0.0.1:${PRIVOXY_PORT:-40080}:8118"
|
| 62 |
+
healthcheck:
|
| 63 |
+
test: ["CMD-SHELL", "nc -z 127.0.0.1 8118 || exit 1"]
|
| 64 |
+
interval: 30s
|
| 65 |
+
timeout: 5s
|
| 66 |
+
retries: 5
|
| 67 |
+
start_period: 10s
|
| 68 |
+
|
| 69 |
+
flaresolverr:
|
| 70 |
+
image: ghcr.io/flaresolverr/flaresolverr:latest
|
| 71 |
+
container_name: chatgpt2api-flaresolverr
|
| 72 |
+
restart: unless-stopped
|
| 73 |
+
environment:
|
| 74 |
+
LOG_LEVEL: ${FLARESOLVERR_LOG_LEVEL:-info}
|
| 75 |
+
TZ: ${TZ:-Asia/Shanghai}
|
| 76 |
+
ports:
|
| 77 |
+
- "127.0.0.1:${FLARESOLVERR_PORT:-8191}:8191"
|
| 78 |
+
healthcheck:
|
| 79 |
+
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8191/ >/dev/null"]
|
| 80 |
+
interval: 30s
|
| 81 |
+
timeout: 10s
|
| 82 |
+
retries: 5
|
| 83 |
+
start_period: 30s
|
| 84 |
+
|
| 85 |
+
app:
|
| 86 |
+
build:
|
| 87 |
+
context: .
|
| 88 |
+
dockerfile: Dockerfile
|
| 89 |
+
image: chatgpt2api:warp
|
| 90 |
+
container_name: chatgpt2api-warp
|
| 91 |
+
restart: unless-stopped
|
| 92 |
+
depends_on:
|
| 93 |
+
init-config:
|
| 94 |
+
condition: service_completed_successfully
|
| 95 |
+
privoxy:
|
| 96 |
+
condition: service_started
|
| 97 |
+
flaresolverr:
|
| 98 |
+
condition: service_started
|
| 99 |
+
ports:
|
| 100 |
+
- "${CHATGPT2API_PORT:-3000}:80"
|
| 101 |
+
volumes:
|
| 102 |
+
- ./data:/app/data
|
| 103 |
+
- ./config.json:/app/config.json
|
| 104 |
+
environment:
|
| 105 |
+
STORAGE_BACKEND: ${STORAGE_BACKEND:-json}
|
| 106 |
+
DATABASE_URL: ${DATABASE_URL:-}
|
| 107 |
+
CHATGPT2API_AUTH_KEY: ${CHATGPT2API_AUTH_KEY:-}
|
| 108 |
+
CHATGPT2API_BASE_URL: ${CHATGPT2API_BASE_URL:-}
|
scripts/init_proxy_config.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Idempotently merge WARP/FlareSolverr proxy_runtime defaults into config.json."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import copy
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
DEFAULT_USER_AGENT = (
|
| 15 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 16 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 17 |
+
"Chrome/145.0.0.0 Safari/537.36"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
DEFAULT_PROXY_RUNTIME: dict[str, Any] = {
|
| 21 |
+
"enabled": False,
|
| 22 |
+
"egress_mode": "direct",
|
| 23 |
+
"proxy_url": "",
|
| 24 |
+
"resource_proxy_url": "",
|
| 25 |
+
"skip_ssl_verify": False,
|
| 26 |
+
"reset_session_status_codes": [403],
|
| 27 |
+
"clearance": {
|
| 28 |
+
"enabled": False,
|
| 29 |
+
"mode": "none",
|
| 30 |
+
"cf_cookies": "",
|
| 31 |
+
"cf_clearance": "",
|
| 32 |
+
"user_agent": DEFAULT_USER_AGENT,
|
| 33 |
+
"browser": "chrome",
|
| 34 |
+
"flaresolverr_url": "",
|
| 35 |
+
"timeout_sec": 60,
|
| 36 |
+
"refresh_interval": 3600,
|
| 37 |
+
"warm_up_on_start": False,
|
| 38 |
+
},
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _env_bool(name: str, default: bool) -> bool:
|
| 43 |
+
value = os.getenv(name)
|
| 44 |
+
if value is None:
|
| 45 |
+
return default
|
| 46 |
+
lowered = value.strip().lower()
|
| 47 |
+
if lowered in {"1", "true", "yes", "on"}:
|
| 48 |
+
return True
|
| 49 |
+
if lowered in {"0", "false", "no", "off"}:
|
| 50 |
+
return False
|
| 51 |
+
return default
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _env_int(name: str, default: int, minimum: int = 0) -> int:
|
| 55 |
+
try:
|
| 56 |
+
return max(minimum, int(os.getenv(name, str(default))))
|
| 57 |
+
except (TypeError, ValueError, OverflowError):
|
| 58 |
+
return default
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _warp_runtime_defaults() -> dict[str, Any]:
|
| 62 |
+
clearance_enabled = _env_bool("CHATGPT2API_PROXY_RUNTIME_CLEARANCE_ENABLED", True)
|
| 63 |
+
clearance_mode = os.getenv("CHATGPT2API_PROXY_RUNTIME_CLEARANCE_MODE", "flaresolverr").strip().lower()
|
| 64 |
+
if clearance_mode not in {"none", "manual", "flaresolverr"}:
|
| 65 |
+
clearance_mode = "flaresolverr" if clearance_enabled else "none"
|
| 66 |
+
if not clearance_enabled:
|
| 67 |
+
clearance_mode = "none"
|
| 68 |
+
|
| 69 |
+
egress_mode = os.getenv("CHATGPT2API_PROXY_RUNTIME_EGRESS_MODE", "single_proxy").strip().lower()
|
| 70 |
+
if egress_mode not in {"direct", "single_proxy"}:
|
| 71 |
+
egress_mode = "single_proxy"
|
| 72 |
+
|
| 73 |
+
runtime = copy.deepcopy(DEFAULT_PROXY_RUNTIME)
|
| 74 |
+
runtime.update(
|
| 75 |
+
{
|
| 76 |
+
"enabled": _env_bool("CHATGPT2API_PROXY_RUNTIME_ENABLED", True),
|
| 77 |
+
"egress_mode": egress_mode,
|
| 78 |
+
"proxy_url": os.getenv("CHATGPT2API_PROXY_RUNTIME_PROXY_URL", "http://privoxy:8118").strip(),
|
| 79 |
+
"resource_proxy_url": os.getenv("CHATGPT2API_PROXY_RUNTIME_RESOURCE_PROXY_URL", "").strip(),
|
| 80 |
+
"skip_ssl_verify": _env_bool("CHATGPT2API_PROXY_RUNTIME_SKIP_SSL_VERIFY", False),
|
| 81 |
+
"reset_session_status_codes": [
|
| 82 |
+
int(part.strip())
|
| 83 |
+
for part in os.getenv("CHATGPT2API_PROXY_RUNTIME_RESET_STATUS_CODES", "403").split(",")
|
| 84 |
+
if part.strip().isdigit() and 100 <= int(part.strip()) <= 599
|
| 85 |
+
] or [403],
|
| 86 |
+
}
|
| 87 |
+
)
|
| 88 |
+
runtime["clearance"].update(
|
| 89 |
+
{
|
| 90 |
+
"enabled": clearance_enabled,
|
| 91 |
+
"mode": clearance_mode,
|
| 92 |
+
"user_agent": os.getenv("CHATGPT2API_PROXY_RUNTIME_USER_AGENT", DEFAULT_USER_AGENT).strip() or DEFAULT_USER_AGENT,
|
| 93 |
+
"browser": os.getenv("CHATGPT2API_PROXY_RUNTIME_BROWSER", "chrome").strip() or "chrome",
|
| 94 |
+
"flaresolverr_url": os.getenv("CHATGPT2API_FLARESOLVERR_URL", "http://flaresolverr:8191").strip(),
|
| 95 |
+
"timeout_sec": _env_int("CHATGPT2API_PROXY_RUNTIME_CLEARANCE_TIMEOUT_SEC", 60, 1),
|
| 96 |
+
"refresh_interval": _env_int("CHATGPT2API_PROXY_RUNTIME_CLEARANCE_REFRESH_INTERVAL", 3600, 60),
|
| 97 |
+
"warm_up_on_start": _env_bool("CHATGPT2API_PROXY_RUNTIME_WARM_UP_ON_START", False),
|
| 98 |
+
}
|
| 99 |
+
)
|
| 100 |
+
return runtime
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _deep_fill_missing(target: dict[str, Any], defaults: dict[str, Any]) -> bool:
|
| 104 |
+
changed = False
|
| 105 |
+
for key, value in defaults.items():
|
| 106 |
+
if key not in target:
|
| 107 |
+
target[key] = copy.deepcopy(value)
|
| 108 |
+
changed = True
|
| 109 |
+
continue
|
| 110 |
+
if isinstance(target[key], dict) and isinstance(value, dict):
|
| 111 |
+
changed = _deep_fill_missing(target[key], value) or changed
|
| 112 |
+
return changed
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _looks_like_repository_default(runtime: Any) -> bool:
|
| 116 |
+
if not isinstance(runtime, dict):
|
| 117 |
+
return True
|
| 118 |
+
candidate = copy.deepcopy(runtime)
|
| 119 |
+
_deep_fill_missing(candidate, DEFAULT_PROXY_RUNTIME)
|
| 120 |
+
return candidate == DEFAULT_PROXY_RUNTIME
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _mask_url(value: str) -> str:
|
| 124 |
+
return re.sub(r"(https?://)([^\s/@:]+):([^\s/@]+)@", r"\1[REDACTED]@", value or "", flags=re.I)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def main() -> int:
|
| 128 |
+
config_path = Path(os.getenv("CHATGPT2API_CONFIG_FILE", "/app/config.json"))
|
| 129 |
+
if not config_path.exists():
|
| 130 |
+
print(f"Config file not found, creating {config_path}")
|
| 131 |
+
data: dict[str, Any] = {}
|
| 132 |
+
else:
|
| 133 |
+
try:
|
| 134 |
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
| 135 |
+
except json.JSONDecodeError as exc:
|
| 136 |
+
print(f"Invalid JSON in {config_path}: {exc}", file=sys.stderr)
|
| 137 |
+
return 1
|
| 138 |
+
if not isinstance(data, dict):
|
| 139 |
+
print(f"Config root must be an object: {config_path}", file=sys.stderr)
|
| 140 |
+
return 1
|
| 141 |
+
|
| 142 |
+
desired = _warp_runtime_defaults()
|
| 143 |
+
existing = data.get("proxy_runtime")
|
| 144 |
+
changed = False
|
| 145 |
+
|
| 146 |
+
if _looks_like_repository_default(existing) or _env_bool("CHATGPT2API_PROXY_RUNTIME_FORCE", False):
|
| 147 |
+
data["proxy_runtime"] = desired
|
| 148 |
+
changed = True
|
| 149 |
+
print("Created proxy_runtime defaults")
|
| 150 |
+
else:
|
| 151 |
+
runtime = existing if isinstance(existing, dict) else {}
|
| 152 |
+
changed = _deep_fill_missing(runtime, DEFAULT_PROXY_RUNTIME)
|
| 153 |
+
data["proxy_runtime"] = runtime
|
| 154 |
+
print("Proxy runtime already configured")
|
| 155 |
+
|
| 156 |
+
if changed:
|
| 157 |
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
| 158 |
+
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
| 159 |
+
tmp_path = config_path.with_suffix(config_path.suffix + ".tmp")
|
| 160 |
+
try:
|
| 161 |
+
tmp_path.write_text(payload, encoding="utf-8")
|
| 162 |
+
tmp_path.replace(config_path)
|
| 163 |
+
except OSError as exc:
|
| 164 |
+
# Docker bind-mounted single files can reject atomic rename with EBUSY.
|
| 165 |
+
# Fall back to in-place write so the init job works with both file and
|
| 166 |
+
# directory mounts.
|
| 167 |
+
if getattr(exc, "errno", None) != 16:
|
| 168 |
+
raise
|
| 169 |
+
config_path.write_text(payload, encoding="utf-8")
|
| 170 |
+
tmp_path.unlink(missing_ok=True)
|
| 171 |
+
|
| 172 |
+
runtime = data.get("proxy_runtime") if isinstance(data.get("proxy_runtime"), dict) else {}
|
| 173 |
+
clearance = runtime.get("clearance") if isinstance(runtime.get("clearance"), dict) else {}
|
| 174 |
+
print(
|
| 175 |
+
"Proxy runtime summary: "
|
| 176 |
+
f"enabled={bool(runtime.get('enabled'))}, "
|
| 177 |
+
f"egress_mode={runtime.get('egress_mode')}, "
|
| 178 |
+
f"proxy_url={_mask_url(str(runtime.get('proxy_url') or ''))}, "
|
| 179 |
+
f"clearance_mode={clearance.get('mode')}, "
|
| 180 |
+
f"flaresolverr_url={_mask_url(str(clearance.get('flaresolverr_url') or ''))}"
|
| 181 |
+
)
|
| 182 |
+
return 0
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
if __name__ == "__main__":
|
| 186 |
+
raise SystemExit(main())
|
scripts/privoxy-warp.conf
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
listen-address 0.0.0.0:8118
|
| 2 |
+
toggle 1
|
| 3 |
+
enable-remote-toggle 0
|
| 4 |
+
enable-remote-http-toggle 0
|
| 5 |
+
enable-edit-actions 0
|
| 6 |
+
enforce-blocks 0
|
| 7 |
+
buffer-limit 4096
|
| 8 |
+
keep-alive-timeout 300
|
| 9 |
+
socket-timeout 300
|
| 10 |
+
|
| 11 |
+
# Forward every HTTP/HTTPS request through the WARP SOCKS5 proxy.
|
| 12 |
+
# The trailing dot is required by Privoxy syntax.
|
| 13 |
+
forward-socks5t / warp-proxy:1080 .
|
services/config.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from dataclasses import dataclass
|
| 4 |
import json
|
| 5 |
import os
|
|
@@ -48,6 +49,33 @@ DEFAULT_CHAT_COMPLETION_CACHE = {
|
|
| 48 |
"drop_assistant_history": False,
|
| 49 |
}
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
def _normalize_bool(value: object, default: bool = False) -> bool:
|
| 53 |
if isinstance(value, str):
|
|
@@ -56,6 +84,7 @@ def _normalize_bool(value: object, default: bool = False) -> bool:
|
|
| 56 |
return True
|
| 57 |
if lowered in {"0", "false", "no", "off"}:
|
| 58 |
return False
|
|
|
|
| 59 |
if value is None:
|
| 60 |
return default
|
| 61 |
return bool(value)
|
|
@@ -64,7 +93,7 @@ def _normalize_bool(value: object, default: bool = False) -> bool:
|
|
| 64 |
def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
|
| 65 |
try:
|
| 66 |
normalized = int(value)
|
| 67 |
-
except (TypeError, ValueError):
|
| 68 |
normalized = default
|
| 69 |
return max(minimum, normalized)
|
| 70 |
|
|
@@ -163,6 +192,84 @@ def _normalize_chat_completion_cache_settings(value: object) -> dict[str, object
|
|
| 163 |
}
|
| 164 |
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
def _validate_image_storage_settings(settings: dict[str, object]) -> None:
|
| 167 |
if not _normalize_bool(settings.get("enabled"), False):
|
| 168 |
return
|
|
@@ -433,12 +540,28 @@ class ConfigStore:
|
|
| 433 |
data["backup"] = self.get_backup_settings()
|
| 434 |
data["image_storage"] = self.get_image_storage_settings()
|
| 435 |
data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
|
|
|
|
| 436 |
data.pop("auth-key", None)
|
| 437 |
return data
|
| 438 |
|
| 439 |
def get_proxy_settings(self) -> str:
|
| 440 |
return str(self.data.get("proxy") or "").strip()
|
| 441 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
def update(self, data: dict[str, object]) -> dict[str, object]:
|
| 443 |
next_data = dict(self.data)
|
| 444 |
next_data.update(dict(data or {}))
|
|
@@ -451,6 +574,15 @@ class ConfigStore:
|
|
| 451 |
next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
|
| 452 |
next_data.get("chat_completion_cache")
|
| 453 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
next_data.pop("backup_state", None)
|
| 455 |
self.data = next_data
|
| 456 |
self._save()
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import copy
|
| 4 |
from dataclasses import dataclass
|
| 5 |
import json
|
| 6 |
import os
|
|
|
|
| 49 |
"drop_assistant_history": False,
|
| 50 |
}
|
| 51 |
|
| 52 |
+
DEFAULT_PROXY_RUNTIME_USER_AGENT = (
|
| 53 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 54 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 55 |
+
"Chrome/145.0.0.0 Safari/537.36"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
DEFAULT_PROXY_RUNTIME = {
|
| 59 |
+
"enabled": False,
|
| 60 |
+
"egress_mode": "direct",
|
| 61 |
+
"proxy_url": "",
|
| 62 |
+
"resource_proxy_url": "",
|
| 63 |
+
"skip_ssl_verify": False,
|
| 64 |
+
"reset_session_status_codes": [403],
|
| 65 |
+
"clearance": {
|
| 66 |
+
"enabled": False,
|
| 67 |
+
"mode": "none",
|
| 68 |
+
"cf_cookies": "",
|
| 69 |
+
"cf_clearance": "",
|
| 70 |
+
"user_agent": DEFAULT_PROXY_RUNTIME_USER_AGENT,
|
| 71 |
+
"browser": "chrome",
|
| 72 |
+
"flaresolverr_url": "",
|
| 73 |
+
"timeout_sec": 60,
|
| 74 |
+
"refresh_interval": 3600,
|
| 75 |
+
"warm_up_on_start": False,
|
| 76 |
+
},
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
|
| 80 |
def _normalize_bool(value: object, default: bool = False) -> bool:
|
| 81 |
if isinstance(value, str):
|
|
|
|
| 84 |
return True
|
| 85 |
if lowered in {"0", "false", "no", "off"}:
|
| 86 |
return False
|
| 87 |
+
return default
|
| 88 |
if value is None:
|
| 89 |
return default
|
| 90 |
return bool(value)
|
|
|
|
| 93 |
def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
|
| 94 |
try:
|
| 95 |
normalized = int(value)
|
| 96 |
+
except (OverflowError, TypeError, ValueError):
|
| 97 |
normalized = default
|
| 98 |
return max(minimum, normalized)
|
| 99 |
|
|
|
|
| 192 |
}
|
| 193 |
|
| 194 |
|
| 195 |
+
def _normalize_status_codes(value: object) -> list[int]:
|
| 196 |
+
items = value if isinstance(value, list) else DEFAULT_PROXY_RUNTIME["reset_session_status_codes"]
|
| 197 |
+
normalized: list[int] = []
|
| 198 |
+
for item in items:
|
| 199 |
+
if isinstance(item, bool):
|
| 200 |
+
continue
|
| 201 |
+
try:
|
| 202 |
+
status = int(item)
|
| 203 |
+
except (OverflowError, TypeError, ValueError):
|
| 204 |
+
continue
|
| 205 |
+
if 100 <= status <= 599 and status not in normalized:
|
| 206 |
+
normalized.append(status)
|
| 207 |
+
if not normalized:
|
| 208 |
+
return list(DEFAULT_PROXY_RUNTIME["reset_session_status_codes"])
|
| 209 |
+
return normalized
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _normalize_proxy_runtime_settings(value: object) -> dict[str, object]:
|
| 213 |
+
source = value if isinstance(value, dict) else {}
|
| 214 |
+
default_clearance = DEFAULT_PROXY_RUNTIME["clearance"]
|
| 215 |
+
clearance_source = source.get("clearance") if isinstance(source.get("clearance"), dict) else {}
|
| 216 |
+
|
| 217 |
+
egress_mode = str(source.get("egress_mode") or DEFAULT_PROXY_RUNTIME["egress_mode"]).strip().lower()
|
| 218 |
+
if egress_mode not in {"direct", "single_proxy"}:
|
| 219 |
+
egress_mode = str(DEFAULT_PROXY_RUNTIME["egress_mode"])
|
| 220 |
+
|
| 221 |
+
clearance_mode = str(clearance_source.get("mode") or default_clearance["mode"]).strip().lower()
|
| 222 |
+
if clearance_mode not in {"none", "manual", "flaresolverr"}:
|
| 223 |
+
clearance_mode = str(default_clearance["mode"])
|
| 224 |
+
|
| 225 |
+
user_agent = str(clearance_source.get("user_agent") or default_clearance["user_agent"]).strip()
|
| 226 |
+
browser = str(clearance_source.get("browser") or default_clearance["browser"]).strip()
|
| 227 |
+
|
| 228 |
+
existing_clearance_cookies = str(source.get("_existing_cf_cookies") or "").strip()
|
| 229 |
+
existing_cf_clearance = str(source.get("_existing_cf_clearance") or "").strip()
|
| 230 |
+
cf_cookies = str(clearance_source.get("cf_cookies") or "").strip()
|
| 231 |
+
cf_clearance = str(clearance_source.get("cf_clearance") or "").strip()
|
| 232 |
+
if not cf_cookies and _normalize_bool(clearance_source.get("has_cf_cookies"), False):
|
| 233 |
+
cf_cookies = existing_clearance_cookies
|
| 234 |
+
if not cf_clearance and _normalize_bool(clearance_source.get("has_cf_clearance"), False):
|
| 235 |
+
cf_clearance = existing_cf_clearance
|
| 236 |
+
|
| 237 |
+
return {
|
| 238 |
+
"enabled": _normalize_bool(source.get("enabled"), bool(DEFAULT_PROXY_RUNTIME["enabled"])),
|
| 239 |
+
"egress_mode": egress_mode,
|
| 240 |
+
"proxy_url": str(source.get("proxy_url") or "").strip(),
|
| 241 |
+
"resource_proxy_url": str(source.get("resource_proxy_url") or "").strip(),
|
| 242 |
+
"skip_ssl_verify": _normalize_bool(
|
| 243 |
+
source.get("skip_ssl_verify"),
|
| 244 |
+
bool(DEFAULT_PROXY_RUNTIME["skip_ssl_verify"]),
|
| 245 |
+
),
|
| 246 |
+
"reset_session_status_codes": _normalize_status_codes(source.get("reset_session_status_codes")),
|
| 247 |
+
"clearance": {
|
| 248 |
+
"enabled": _normalize_bool(clearance_source.get("enabled"), bool(default_clearance["enabled"])),
|
| 249 |
+
"mode": clearance_mode,
|
| 250 |
+
"cf_cookies": cf_cookies,
|
| 251 |
+
"cf_clearance": cf_clearance,
|
| 252 |
+
"user_agent": user_agent or str(default_clearance["user_agent"]),
|
| 253 |
+
"browser": browser or str(default_clearance["browser"]),
|
| 254 |
+
"flaresolverr_url": str(clearance_source.get("flaresolverr_url") or "").strip(),
|
| 255 |
+
"timeout_sec": _normalize_positive_int(
|
| 256 |
+
clearance_source.get("timeout_sec"),
|
| 257 |
+
int(default_clearance["timeout_sec"]),
|
| 258 |
+
1,
|
| 259 |
+
),
|
| 260 |
+
"refresh_interval": _normalize_positive_int(
|
| 261 |
+
clearance_source.get("refresh_interval"),
|
| 262 |
+
int(default_clearance["refresh_interval"]),
|
| 263 |
+
60,
|
| 264 |
+
),
|
| 265 |
+
"warm_up_on_start": _normalize_bool(
|
| 266 |
+
clearance_source.get("warm_up_on_start"),
|
| 267 |
+
bool(default_clearance["warm_up_on_start"]),
|
| 268 |
+
),
|
| 269 |
+
},
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
def _validate_image_storage_settings(settings: dict[str, object]) -> None:
|
| 274 |
if not _normalize_bool(settings.get("enabled"), False):
|
| 275 |
return
|
|
|
|
| 540 |
data["backup"] = self.get_backup_settings()
|
| 541 |
data["image_storage"] = self.get_image_storage_settings()
|
| 542 |
data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
|
| 543 |
+
data["proxy_runtime"] = self.get_public_proxy_runtime_settings()
|
| 544 |
data.pop("auth-key", None)
|
| 545 |
return data
|
| 546 |
|
| 547 |
def get_proxy_settings(self) -> str:
|
| 548 |
return str(self.data.get("proxy") or "").strip()
|
| 549 |
|
| 550 |
+
def get_proxy_runtime_settings(self) -> dict[str, object]:
|
| 551 |
+
return _normalize_proxy_runtime_settings(self.data.get("proxy_runtime"))
|
| 552 |
+
|
| 553 |
+
def get_public_proxy_runtime_settings(self) -> dict[str, object]:
|
| 554 |
+
runtime = copy.deepcopy(self.get_proxy_runtime_settings())
|
| 555 |
+
clearance = runtime.get("clearance") if isinstance(runtime.get("clearance"), dict) else {}
|
| 556 |
+
if isinstance(clearance, dict):
|
| 557 |
+
cf_cookies = str(clearance.get("cf_cookies") or "").strip()
|
| 558 |
+
cf_clearance = str(clearance.get("cf_clearance") or "").strip()
|
| 559 |
+
clearance["cf_cookies"] = ""
|
| 560 |
+
clearance["cf_clearance"] = ""
|
| 561 |
+
clearance["has_cf_cookies"] = bool(cf_cookies)
|
| 562 |
+
clearance["has_cf_clearance"] = bool(cf_clearance)
|
| 563 |
+
return runtime
|
| 564 |
+
|
| 565 |
def update(self, data: dict[str, object]) -> dict[str, object]:
|
| 566 |
next_data = dict(self.data)
|
| 567 |
next_data.update(dict(data or {}))
|
|
|
|
| 574 |
next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
|
| 575 |
next_data.get("chat_completion_cache")
|
| 576 |
)
|
| 577 |
+
if "proxy_runtime" in next_data:
|
| 578 |
+
incoming_runtime = next_data.get("proxy_runtime")
|
| 579 |
+
if isinstance(incoming_runtime, dict):
|
| 580 |
+
previous_clearance = self.get_proxy_runtime_settings().get("clearance")
|
| 581 |
+
if isinstance(previous_clearance, dict):
|
| 582 |
+
incoming_runtime = dict(incoming_runtime)
|
| 583 |
+
incoming_runtime["_existing_cf_cookies"] = previous_clearance.get("cf_cookies")
|
| 584 |
+
incoming_runtime["_existing_cf_clearance"] = previous_clearance.get("cf_clearance")
|
| 585 |
+
next_data["proxy_runtime"] = _normalize_proxy_runtime_settings(incoming_runtime)
|
| 586 |
next_data.pop("backup_state", None)
|
| 587 |
self.data = next_data
|
| 588 |
self._save()
|
services/proxy_service.py
CHANGED
|
@@ -1,8 +1,14 @@
|
|
| 1 |
-
"""Global outbound proxy
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import time
|
|
|
|
|
|
|
| 6 |
from urllib.parse import urlparse
|
| 7 |
|
| 8 |
from curl_cffi.requests import Session
|
|
@@ -10,30 +16,543 @@ from curl_cffi.requests import Session
|
|
| 10 |
from services.config import config
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
class ProxySettingsStore:
|
| 14 |
-
def
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return session_kwargs
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
def _clean(value: object) -> str:
|
| 23 |
return str(value or "").strip()
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def _is_valid_proxy_url(url: str) -> bool:
|
| 27 |
-
parsed = urlparse(url)
|
| 28 |
return parsed.scheme in {"http", "https", "socks5", "socks5h"} and bool(parsed.netloc)
|
| 29 |
|
| 30 |
|
| 31 |
-
def
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if not candidate:
|
| 34 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
if not _is_valid_proxy_url(candidate):
|
| 36 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
session = Session(impersonate="edge101", verify=True, proxy=candidate)
|
| 38 |
started = time.perf_counter()
|
| 39 |
try:
|
|
@@ -48,6 +567,7 @@ def test_proxy(url: str, *, timeout: float = 15.0) -> dict:
|
|
| 48 |
"status": int(response.status_code),
|
| 49 |
"latency_ms": latency_ms,
|
| 50 |
"error": None if response.status_code < 500 else f"HTTP {response.status_code}",
|
|
|
|
| 51 |
}
|
| 52 |
except Exception as exc:
|
| 53 |
latency_ms = int((time.perf_counter() - started) * 1000)
|
|
@@ -55,10 +575,62 @@ def test_proxy(url: str, *, timeout: float = 15.0) -> dict:
|
|
| 55 |
"ok": False,
|
| 56 |
"status": 0,
|
| 57 |
"latency_ms": latency_ms,
|
| 58 |
-
"error": str(exc) or exc.__class__.__name__,
|
|
|
|
| 59 |
}
|
| 60 |
finally:
|
| 61 |
session.close()
|
| 62 |
|
| 63 |
-
proxy_settings = ProxySettingsStore()
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Global outbound proxy and Cloudflare clearance helpers."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from dataclasses import dataclass, field, replace
|
| 6 |
+
import json
|
| 7 |
+
import re
|
| 8 |
+
import threading
|
| 9 |
import time
|
| 10 |
+
from typing import Callable, Mapping
|
| 11 |
+
from urllib import request as urllib_request
|
| 12 |
from urllib.parse import urlparse
|
| 13 |
|
| 14 |
from curl_cffi.requests import Session
|
|
|
|
| 16 |
from services.config import config
|
| 17 |
|
| 18 |
|
| 19 |
+
FlareSolverrRequestMethod = Callable[[str, bytes, dict[str, str], float], bytes]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def normalize_proxy_url(url: str) -> str:
|
| 23 |
+
"""Normalize proxy URLs for curl_cffi.
|
| 24 |
+
|
| 25 |
+
SOCKS proxies should use remote-DNS resolution by default, so generic
|
| 26 |
+
``socks://`` and ``socks5://`` inputs are upgraded to ``socks5h://``.
|
| 27 |
+
HTTP/HTTPS/socks5h inputs are otherwise left untouched except trimming.
|
| 28 |
+
"""
|
| 29 |
+
candidate = str(url or "").strip()
|
| 30 |
+
lowered = candidate.lower()
|
| 31 |
+
if lowered.startswith("socks://"):
|
| 32 |
+
return "socks5h://" + candidate[len("socks://") :]
|
| 33 |
+
if lowered.startswith("socks5://"):
|
| 34 |
+
return "socks5h://" + candidate[len("socks5://") :]
|
| 35 |
+
return candidate
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class ProxyRuntimeProfile:
|
| 40 |
+
proxy_url: str = ""
|
| 41 |
+
proxy_source: str = "direct"
|
| 42 |
+
resource: bool = False
|
| 43 |
+
runtime_enabled: bool = False
|
| 44 |
+
egress_mode: str = "direct"
|
| 45 |
+
skip_ssl_verify: bool = False
|
| 46 |
+
reset_session_status_codes: tuple[int, ...] = field(default_factory=lambda: (403,))
|
| 47 |
+
clearance: dict[str, object] = field(default_factory=dict, repr=False)
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def clearance_enabled(self) -> bool:
|
| 51 |
+
return (
|
| 52 |
+
self.runtime_enabled
|
| 53 |
+
and bool(self.clearance.get("enabled"))
|
| 54 |
+
and self.clearance_mode in {"manual", "flaresolverr"}
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def clearance_mode(self) -> str:
|
| 59 |
+
return str(self.clearance.get("mode") or "none").strip().lower()
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def refresh_interval(self) -> int:
|
| 63 |
+
try:
|
| 64 |
+
return max(0, int(self.clearance.get("refresh_interval") or 0))
|
| 65 |
+
except (OverflowError, TypeError, ValueError):
|
| 66 |
+
return 0
|
| 67 |
+
|
| 68 |
+
@property
|
| 69 |
+
def timeout_sec(self) -> int:
|
| 70 |
+
try:
|
| 71 |
+
return max(1, int(self.clearance.get("timeout_sec") or 60))
|
| 72 |
+
except (OverflowError, TypeError, ValueError):
|
| 73 |
+
return 60
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass(frozen=True)
|
| 77 |
+
class ClearanceBundle:
|
| 78 |
+
target_host: str
|
| 79 |
+
proxy_url: str = ""
|
| 80 |
+
cookies: dict[str, str] = field(default_factory=dict, repr=False)
|
| 81 |
+
user_agent: str = ""
|
| 82 |
+
created_at: float = field(default_factory=time.time)
|
| 83 |
+
expires_at: float | None = None
|
| 84 |
+
|
| 85 |
+
def is_valid_for(self, target_host: str, proxy_url: str, *, now: float | None = None) -> bool:
|
| 86 |
+
host = _normalize_host(target_host)
|
| 87 |
+
if self.target_host and host and _normalize_host(self.target_host) != host:
|
| 88 |
+
return False
|
| 89 |
+
if normalize_proxy_url(self.proxy_url) != normalize_proxy_url(proxy_url):
|
| 90 |
+
return False
|
| 91 |
+
if self.expires_at is not None and (time.time() if now is None else now) >= self.expires_at:
|
| 92 |
+
return False
|
| 93 |
+
return bool(self.cookies or self.user_agent)
|
| 94 |
+
|
| 95 |
+
def cookie_header(self) -> str:
|
| 96 |
+
return _cookies_to_header(self.cookies)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class FlareSolverrClearanceProvider:
|
| 100 |
+
def __init__(self, flaresolverr_url: str, request_method: FlareSolverrRequestMethod | None = None) -> None:
|
| 101 |
+
self.flaresolverr_url = str(flaresolverr_url or "").strip().rstrip("/")
|
| 102 |
+
self._request_method = request_method or self._urllib_post
|
| 103 |
+
|
| 104 |
+
def get_clearance(self, target_url: str, proxy_url: str = "", timeout_sec: int = 60) -> ClearanceBundle | None:
|
| 105 |
+
if not self.flaresolverr_url:
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
timeout = _coerce_timeout(timeout_sec)
|
| 109 |
+
payload: dict[str, object] = {
|
| 110 |
+
"cmd": "request.get",
|
| 111 |
+
"url": str(target_url or ""),
|
| 112 |
+
"maxTimeout": int(timeout * 1000),
|
| 113 |
+
}
|
| 114 |
+
proxy_url = normalize_proxy_url(proxy_url)
|
| 115 |
+
if proxy_url:
|
| 116 |
+
payload["proxy"] = {"url": proxy_url}
|
| 117 |
+
|
| 118 |
+
endpoint = f"{self.flaresolverr_url}/v1"
|
| 119 |
+
try:
|
| 120 |
+
body = json.dumps(payload).encode("utf-8")
|
| 121 |
+
raw_response = self._request_method(
|
| 122 |
+
endpoint,
|
| 123 |
+
body,
|
| 124 |
+
{"Content-Type": "application/json"},
|
| 125 |
+
timeout,
|
| 126 |
+
)
|
| 127 |
+
data = json.loads(raw_response.decode("utf-8") if isinstance(raw_response, bytes) else raw_response)
|
| 128 |
+
except Exception:
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
if not isinstance(data, dict) or str(data.get("status") or "").lower() != "ok":
|
| 132 |
+
return None
|
| 133 |
+
solution = data.get("solution")
|
| 134 |
+
if not isinstance(solution, dict):
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
target_host = _host_from_url(target_url)
|
| 138 |
+
cookies = _filter_flaresolverr_cookies(solution.get("cookies"), target_host)
|
| 139 |
+
user_agent = str(solution.get("userAgent") or "").strip()
|
| 140 |
+
if not cookies and not user_agent:
|
| 141 |
+
return None
|
| 142 |
+
return ClearanceBundle(
|
| 143 |
+
target_host=target_host,
|
| 144 |
+
proxy_url=proxy_url,
|
| 145 |
+
cookies=cookies,
|
| 146 |
+
user_agent=user_agent,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
@staticmethod
|
| 150 |
+
def _urllib_post(endpoint: str, body: bytes, headers: dict[str, str], timeout: float) -> bytes:
|
| 151 |
+
req = urllib_request.Request(endpoint, data=body, headers=headers, method="POST")
|
| 152 |
+
with urllib_request.urlopen(req, timeout=timeout) as response:
|
| 153 |
+
return response.read()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
class ProxySettingsStore:
|
| 157 |
+
def __init__(
|
| 158 |
+
self,
|
| 159 |
+
config_store=None,
|
| 160 |
+
clearance_provider_factory: Callable[[str], FlareSolverrClearanceProvider] | None = None,
|
| 161 |
+
) -> None:
|
| 162 |
+
self._config = config_store or config
|
| 163 |
+
self._clearance_provider_factory = clearance_provider_factory or FlareSolverrClearanceProvider
|
| 164 |
+
self._clearance_cache: dict[tuple[str, str], ClearanceBundle] = {}
|
| 165 |
+
self._provider_cache: dict[str, FlareSolverrClearanceProvider] = {}
|
| 166 |
+
self._flight_locks: dict[tuple[str, str], threading.Lock] = {}
|
| 167 |
+
self._lock = threading.RLock()
|
| 168 |
+
|
| 169 |
+
def get_profile(
|
| 170 |
+
self,
|
| 171 |
+
account: dict | None = None,
|
| 172 |
+
proxy: str = "",
|
| 173 |
+
resource: bool = False,
|
| 174 |
+
upstream: bool = False,
|
| 175 |
+
) -> ProxyRuntimeProfile:
|
| 176 |
+
runtime = self._get_runtime_settings()
|
| 177 |
+
clearance = dict(runtime.get("clearance") if isinstance(runtime.get("clearance"), dict) else {})
|
| 178 |
+
runtime_enabled = bool(runtime.get("enabled"))
|
| 179 |
+
egress_mode = str(runtime.get("egress_mode") or "direct").strip().lower()
|
| 180 |
+
|
| 181 |
+
account_proxy = _clean((account or {}).get("proxy") if isinstance(account, dict) else "")
|
| 182 |
+
explicit_proxy = _clean(proxy)
|
| 183 |
+
legacy_proxy = _clean(self._config.get_proxy_settings())
|
| 184 |
+
|
| 185 |
+
runtime_proxy = ""
|
| 186 |
+
runtime_proxy_source = "runtime"
|
| 187 |
+
if upstream and runtime_enabled and egress_mode == "single_proxy":
|
| 188 |
+
resource_proxy = _clean(runtime.get("resource_proxy_url")) if resource else ""
|
| 189 |
+
runtime_proxy = resource_proxy or _clean(runtime.get("proxy_url"))
|
| 190 |
+
runtime_proxy_source = "runtime_resource" if resource_proxy else "runtime"
|
| 191 |
+
|
| 192 |
+
selected_proxy = ""
|
| 193 |
+
source = "direct"
|
| 194 |
+
if account_proxy:
|
| 195 |
+
selected_proxy = account_proxy
|
| 196 |
+
source = "account"
|
| 197 |
+
elif runtime_proxy:
|
| 198 |
+
selected_proxy = runtime_proxy
|
| 199 |
+
source = runtime_proxy_source
|
| 200 |
+
elif explicit_proxy:
|
| 201 |
+
selected_proxy = explicit_proxy
|
| 202 |
+
source = "explicit"
|
| 203 |
+
elif legacy_proxy:
|
| 204 |
+
selected_proxy = legacy_proxy
|
| 205 |
+
source = "global"
|
| 206 |
+
|
| 207 |
+
return ProxyRuntimeProfile(
|
| 208 |
+
proxy_url=normalize_proxy_url(selected_proxy),
|
| 209 |
+
proxy_source=source,
|
| 210 |
+
resource=bool(resource),
|
| 211 |
+
runtime_enabled=runtime_enabled,
|
| 212 |
+
egress_mode=egress_mode,
|
| 213 |
+
skip_ssl_verify=bool(runtime.get("skip_ssl_verify")) if runtime_enabled else False,
|
| 214 |
+
reset_session_status_codes=_status_codes_tuple(runtime.get("reset_session_status_codes")),
|
| 215 |
+
clearance=clearance,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
def build_session_kwargs(
|
| 219 |
+
self,
|
| 220 |
+
account: dict | None = None,
|
| 221 |
+
proxy: str = "",
|
| 222 |
+
resource: bool = False,
|
| 223 |
+
upstream: bool = False,
|
| 224 |
+
**session_kwargs,
|
| 225 |
+
) -> dict[str, object]:
|
| 226 |
+
profile = self.get_profile(account=account, proxy=proxy, resource=resource, upstream=upstream)
|
| 227 |
+
if profile.proxy_url:
|
| 228 |
+
session_kwargs["proxy"] = profile.proxy_url
|
| 229 |
+
if profile.runtime_enabled and profile.skip_ssl_verify:
|
| 230 |
+
session_kwargs["verify"] = False
|
| 231 |
return session_kwargs
|
| 232 |
|
| 233 |
+
def build_headers(
|
| 234 |
+
self,
|
| 235 |
+
headers: Mapping[str, object] | None = None,
|
| 236 |
+
target_url: str = "https://chatgpt.com",
|
| 237 |
+
account: dict | None = None,
|
| 238 |
+
proxy: str = "",
|
| 239 |
+
resource: bool = False,
|
| 240 |
+
upstream: bool = True,
|
| 241 |
+
) -> dict[str, object]:
|
| 242 |
+
merged_headers: dict[str, object] = dict(headers or {})
|
| 243 |
+
profile = self.get_profile(account=account, proxy=proxy, resource=resource, upstream=upstream)
|
| 244 |
+
if not profile.clearance_enabled:
|
| 245 |
+
return merged_headers
|
| 246 |
+
|
| 247 |
+
target_host = _host_from_url(target_url)
|
| 248 |
+
bundle = self._bundle_for_headers(profile, target_host)
|
| 249 |
+
if bundle is None or not bundle.is_valid_for(target_host, profile.proxy_url):
|
| 250 |
+
return merged_headers
|
| 251 |
+
|
| 252 |
+
if bundle.user_agent and _find_header_key(merged_headers, "user-agent") is None:
|
| 253 |
+
merged_headers["User-Agent"] = bundle.user_agent
|
| 254 |
+
|
| 255 |
+
if bundle.cookies:
|
| 256 |
+
cookie_key = _find_header_key(merged_headers, "cookie") or "Cookie"
|
| 257 |
+
existing_cookie = str(merged_headers.get(cookie_key) or "")
|
| 258 |
+
cookie_header = _merge_cookie_header(existing_cookie, bundle.cookies)
|
| 259 |
+
if cookie_header:
|
| 260 |
+
merged_headers[cookie_key] = cookie_header
|
| 261 |
+
return merged_headers
|
| 262 |
+
|
| 263 |
+
def refresh_clearance(
|
| 264 |
+
self,
|
| 265 |
+
target_url: str = "https://chatgpt.com",
|
| 266 |
+
account: dict | None = None,
|
| 267 |
+
proxy: str = "",
|
| 268 |
+
resource: bool = False,
|
| 269 |
+
force: bool = False,
|
| 270 |
+
upstream: bool = True,
|
| 271 |
+
) -> ClearanceBundle | None:
|
| 272 |
+
profile = self.get_profile(account=account, proxy=proxy, resource=resource, upstream=upstream)
|
| 273 |
+
if not profile.clearance_enabled:
|
| 274 |
+
return None
|
| 275 |
+
|
| 276 |
+
target_host = _host_from_url(target_url)
|
| 277 |
+
key = self._cache_key(profile.proxy_url, target_host)
|
| 278 |
+
if profile.clearance_mode == "manual":
|
| 279 |
+
bundle = self._build_manual_bundle(profile, target_host)
|
| 280 |
+
if bundle is not None:
|
| 281 |
+
self._set_cached_bundle(key, bundle)
|
| 282 |
+
return bundle
|
| 283 |
+
if profile.clearance_mode != "flaresolverr":
|
| 284 |
+
return None
|
| 285 |
+
|
| 286 |
+
cached_before = self._get_cached_bundle(key)
|
| 287 |
+
if cached_before is not None and not force and cached_before.is_valid_for(target_host, profile.proxy_url):
|
| 288 |
+
return cached_before
|
| 289 |
+
|
| 290 |
+
lock = self._get_flight_lock(key)
|
| 291 |
+
if not lock.acquire(blocking=False):
|
| 292 |
+
with lock:
|
| 293 |
+
pass
|
| 294 |
+
return self._get_cached_bundle(key) or cached_before
|
| 295 |
+
|
| 296 |
+
try:
|
| 297 |
+
cached_now = self._get_cached_bundle(key)
|
| 298 |
+
if cached_now is not None and not force and cached_now.is_valid_for(target_host, profile.proxy_url):
|
| 299 |
+
return cached_now
|
| 300 |
+
|
| 301 |
+
flaresolverr_url = str(profile.clearance.get("flaresolverr_url") or "").strip()
|
| 302 |
+
provider = self._get_provider(flaresolverr_url)
|
| 303 |
+
new_bundle = provider.get_clearance(target_url, proxy_url=profile.proxy_url, timeout_sec=profile.timeout_sec)
|
| 304 |
+
if new_bundle is not None:
|
| 305 |
+
expires_at = time.time() + profile.refresh_interval if profile.refresh_interval else None
|
| 306 |
+
if (
|
| 307 |
+
not new_bundle.target_host
|
| 308 |
+
or normalize_proxy_url(new_bundle.proxy_url) != normalize_proxy_url(profile.proxy_url)
|
| 309 |
+
or new_bundle.expires_at != expires_at
|
| 310 |
+
):
|
| 311 |
+
new_bundle = replace(
|
| 312 |
+
new_bundle,
|
| 313 |
+
target_host=new_bundle.target_host or target_host,
|
| 314 |
+
proxy_url=profile.proxy_url,
|
| 315 |
+
expires_at=expires_at,
|
| 316 |
+
)
|
| 317 |
+
self._set_cached_bundle(key, new_bundle)
|
| 318 |
+
return new_bundle
|
| 319 |
+
return cached_now or cached_before
|
| 320 |
+
finally:
|
| 321 |
+
lock.release()
|
| 322 |
+
|
| 323 |
+
def invalidate_clearance(
|
| 324 |
+
self,
|
| 325 |
+
target_url: str = "https://chatgpt.com",
|
| 326 |
+
account: dict | None = None,
|
| 327 |
+
proxy: str = "",
|
| 328 |
+
resource: bool = False,
|
| 329 |
+
upstream: bool = True,
|
| 330 |
+
) -> None:
|
| 331 |
+
profile = self.get_profile(account=account, proxy=proxy, resource=resource, upstream=upstream)
|
| 332 |
+
target_host = _host_from_url(target_url)
|
| 333 |
+
key = self._cache_key(profile.proxy_url, target_host)
|
| 334 |
+
with self._lock:
|
| 335 |
+
self._clearance_cache.pop(key, None)
|
| 336 |
+
|
| 337 |
+
def get_runtime_status(self) -> dict[str, object]:
|
| 338 |
+
profile = self.get_profile(upstream=True)
|
| 339 |
+
with self._lock:
|
| 340 |
+
cached_hosts = [host for _proxy, host in self._clearance_cache]
|
| 341 |
+
cached_count = len(self._clearance_cache)
|
| 342 |
+
return {
|
| 343 |
+
"enabled": profile.runtime_enabled,
|
| 344 |
+
"egress_mode": profile.egress_mode,
|
| 345 |
+
"proxy_source": profile.proxy_source,
|
| 346 |
+
"has_proxy": bool(profile.proxy_url),
|
| 347 |
+
"clearance_enabled": profile.clearance_enabled,
|
| 348 |
+
"clearance_mode": profile.clearance_mode,
|
| 349 |
+
"has_clearance_bundle": cached_count > 0,
|
| 350 |
+
"cached_clearance_hosts": sorted(set(cached_hosts)),
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
def _get_runtime_settings(self) -> dict[str, object]:
|
| 354 |
+
try:
|
| 355 |
+
runtime = self._config.get_proxy_runtime_settings()
|
| 356 |
+
except AttributeError:
|
| 357 |
+
runtime = {}
|
| 358 |
+
return runtime if isinstance(runtime, dict) else {}
|
| 359 |
+
|
| 360 |
+
def _bundle_for_headers(self, profile: ProxyRuntimeProfile, target_host: str) -> ClearanceBundle | None:
|
| 361 |
+
key = self._cache_key(profile.proxy_url, target_host)
|
| 362 |
+
if profile.clearance_mode == "manual":
|
| 363 |
+
bundle = self._build_manual_bundle(profile, target_host)
|
| 364 |
+
if bundle is not None:
|
| 365 |
+
self._set_cached_bundle(key, bundle)
|
| 366 |
+
return bundle
|
| 367 |
+
if profile.clearance_mode == "flaresolverr":
|
| 368 |
+
return self._get_cached_bundle(key)
|
| 369 |
+
return None
|
| 370 |
+
|
| 371 |
+
def _build_manual_bundle(self, profile: ProxyRuntimeProfile, target_host: str) -> ClearanceBundle | None:
|
| 372 |
+
cookies = _parse_cookie_header(str(profile.clearance.get("cf_cookies") or ""))
|
| 373 |
+
cf_clearance = str(profile.clearance.get("cf_clearance") or "").strip()
|
| 374 |
+
if cf_clearance and "cf_clearance" not in cookies:
|
| 375 |
+
cookies["cf_clearance"] = cf_clearance
|
| 376 |
+
user_agent = str(profile.clearance.get("user_agent") or "").strip()
|
| 377 |
+
if not cookies and not user_agent:
|
| 378 |
+
return None
|
| 379 |
+
|
| 380 |
+
now = time.time()
|
| 381 |
+
expires_at = now + profile.refresh_interval if profile.refresh_interval else None
|
| 382 |
+
return ClearanceBundle(
|
| 383 |
+
target_host=target_host,
|
| 384 |
+
proxy_url=profile.proxy_url,
|
| 385 |
+
cookies=cookies,
|
| 386 |
+
user_agent=user_agent,
|
| 387 |
+
created_at=now,
|
| 388 |
+
expires_at=expires_at,
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
def _get_provider(self, flaresolverr_url: str) -> FlareSolverrClearanceProvider:
|
| 392 |
+
url = str(flaresolverr_url or "").strip().rstrip("/")
|
| 393 |
+
with self._lock:
|
| 394 |
+
provider = self._provider_cache.get(url)
|
| 395 |
+
if provider is None:
|
| 396 |
+
provider = self._clearance_provider_factory(url)
|
| 397 |
+
self._provider_cache[url] = provider
|
| 398 |
+
return provider
|
| 399 |
+
|
| 400 |
+
def _get_flight_lock(self, key: tuple[str, str]) -> threading.Lock:
|
| 401 |
+
with self._lock:
|
| 402 |
+
lock = self._flight_locks.get(key)
|
| 403 |
+
if lock is None:
|
| 404 |
+
lock = threading.Lock()
|
| 405 |
+
self._flight_locks[key] = lock
|
| 406 |
+
return lock
|
| 407 |
+
|
| 408 |
+
def _get_cached_bundle(self, key: tuple[str, str]) -> ClearanceBundle | None:
|
| 409 |
+
with self._lock:
|
| 410 |
+
return self._clearance_cache.get(key)
|
| 411 |
+
|
| 412 |
+
def _set_cached_bundle(self, key: tuple[str, str], bundle: ClearanceBundle) -> None:
|
| 413 |
+
with self._lock:
|
| 414 |
+
self._clearance_cache[key] = bundle
|
| 415 |
+
|
| 416 |
+
@staticmethod
|
| 417 |
+
def _cache_key(proxy_url: str, target_host: str) -> tuple[str, str]:
|
| 418 |
+
return (normalize_proxy_url(proxy_url), _normalize_host(target_host))
|
| 419 |
+
|
| 420 |
|
| 421 |
def _clean(value: object) -> str:
|
| 422 |
return str(value or "").strip()
|
| 423 |
|
| 424 |
|
| 425 |
+
def _normalize_host(host: str) -> str:
|
| 426 |
+
return str(host or "").strip().strip(".").lower()
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _host_from_url(url: str) -> str:
|
| 430 |
+
candidate = str(url or "").strip()
|
| 431 |
+
parsed = urlparse(candidate)
|
| 432 |
+
if not parsed.hostname and candidate and "://" not in candidate:
|
| 433 |
+
parsed = urlparse(f"https://{candidate}")
|
| 434 |
+
return _normalize_host(parsed.hostname or "")
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def _status_codes_tuple(value: object) -> tuple[int, ...]:
|
| 438 |
+
source = value if isinstance(value, list) else [403]
|
| 439 |
+
codes: list[int] = []
|
| 440 |
+
for item in source:
|
| 441 |
+
if isinstance(item, bool):
|
| 442 |
+
continue
|
| 443 |
+
try:
|
| 444 |
+
code = int(item)
|
| 445 |
+
except (OverflowError, TypeError, ValueError):
|
| 446 |
+
continue
|
| 447 |
+
if 100 <= code <= 599 and code not in codes:
|
| 448 |
+
codes.append(code)
|
| 449 |
+
return tuple(codes or [403])
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def _coerce_timeout(value: object) -> float:
|
| 453 |
+
try:
|
| 454 |
+
timeout = float(value)
|
| 455 |
+
except (OverflowError, TypeError, ValueError):
|
| 456 |
+
timeout = 60.0
|
| 457 |
+
return max(1.0, timeout)
|
| 458 |
+
|
| 459 |
+
|
| 460 |
def _is_valid_proxy_url(url: str) -> bool:
|
| 461 |
+
parsed = urlparse(normalize_proxy_url(url))
|
| 462 |
return parsed.scheme in {"http", "https", "socks5", "socks5h"} and bool(parsed.netloc)
|
| 463 |
|
| 464 |
|
| 465 |
+
def _domain_matches(host: str, domain: str) -> bool:
|
| 466 |
+
normalized_host = _normalize_host(host)
|
| 467 |
+
normalized_domain = _normalize_host(domain.lstrip("."))
|
| 468 |
+
if not normalized_domain:
|
| 469 |
+
return True
|
| 470 |
+
return normalized_host == normalized_domain or normalized_host.endswith(f".{normalized_domain}")
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _filter_flaresolverr_cookies(raw_cookies: object, target_host: str) -> dict[str, str]:
|
| 474 |
+
if not isinstance(raw_cookies, list):
|
| 475 |
+
return {}
|
| 476 |
+
|
| 477 |
+
filtered_cookies: dict[str, str] = {}
|
| 478 |
+
for item in raw_cookies:
|
| 479 |
+
if not isinstance(item, dict):
|
| 480 |
+
continue
|
| 481 |
+
name = str(item.get("name") or "").strip()
|
| 482 |
+
if not name:
|
| 483 |
+
continue
|
| 484 |
+
value = str(item.get("value") or "")
|
| 485 |
+
domain = str(item.get("domain") or "").strip()
|
| 486 |
+
if not domain or _domain_matches(target_host, domain):
|
| 487 |
+
filtered_cookies[name] = value
|
| 488 |
+
return filtered_cookies
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def _parse_cookie_header(header: str) -> dict[str, str]:
|
| 492 |
+
cookies: dict[str, str] = {}
|
| 493 |
+
for part in str(header or "").split(";"):
|
| 494 |
+
name, sep, value = part.strip().partition("=")
|
| 495 |
+
if sep and name:
|
| 496 |
+
cookies[name.strip()] = value.strip()
|
| 497 |
+
return cookies
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def _cookies_to_header(cookies: Mapping[str, str]) -> str:
|
| 501 |
+
return "; ".join(f"{name}={value}" for name, value in cookies.items() if name)
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def _merge_cookie_header(existing_header: str, cookies: Mapping[str, str]) -> str:
|
| 505 |
+
existing = str(existing_header or "").strip()
|
| 506 |
+
existing_names = set(_parse_cookie_header(existing).keys())
|
| 507 |
+
additions = [f"{name}={value}" for name, value in cookies.items() if name and name not in existing_names]
|
| 508 |
+
if existing and additions:
|
| 509 |
+
return existing.rstrip("; ") + "; " + "; ".join(additions)
|
| 510 |
+
if existing:
|
| 511 |
+
return existing
|
| 512 |
+
return "; ".join(additions)
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def _find_header_key(headers: Mapping[str, object], name: str) -> str | None:
|
| 516 |
+
target = name.lower()
|
| 517 |
+
for key in headers:
|
| 518 |
+
if str(key).lower() == target:
|
| 519 |
+
return str(key)
|
| 520 |
+
return None
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
def _redact_url_credentials(text: str) -> str:
|
| 524 |
+
return re.sub(
|
| 525 |
+
r"(https?://)([^\s/@:]+):([^\s/@]+)@",
|
| 526 |
+
r"\1[REDACTED]@",
|
| 527 |
+
str(text or ""),
|
| 528 |
+
flags=re.IGNORECASE,
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def test_proxy(url: str = "", *, timeout: float = 15.0) -> dict:
|
| 533 |
+
candidate = normalize_proxy_url(_clean(url))
|
| 534 |
+
proxy_source = "input"
|
| 535 |
+
if not candidate:
|
| 536 |
+
profile = proxy_settings.get_profile(upstream=True)
|
| 537 |
+
candidate = profile.proxy_url
|
| 538 |
+
proxy_source = profile.proxy_source
|
| 539 |
+
result_base = {"proxy_source": proxy_source, "has_proxy": bool(candidate)}
|
| 540 |
if not candidate:
|
| 541 |
+
return {
|
| 542 |
+
"ok": False,
|
| 543 |
+
"status": 0,
|
| 544 |
+
"latency_ms": 0,
|
| 545 |
+
"error": "no active proxy configured",
|
| 546 |
+
**result_base,
|
| 547 |
+
}
|
| 548 |
if not _is_valid_proxy_url(candidate):
|
| 549 |
+
return {
|
| 550 |
+
"ok": False,
|
| 551 |
+
"status": 0,
|
| 552 |
+
"latency_ms": 0,
|
| 553 |
+
"error": "invalid proxy url",
|
| 554 |
+
**result_base,
|
| 555 |
+
}
|
| 556 |
session = Session(impersonate="edge101", verify=True, proxy=candidate)
|
| 557 |
started = time.perf_counter()
|
| 558 |
try:
|
|
|
|
| 567 |
"status": int(response.status_code),
|
| 568 |
"latency_ms": latency_ms,
|
| 569 |
"error": None if response.status_code < 500 else f"HTTP {response.status_code}",
|
| 570 |
+
**result_base,
|
| 571 |
}
|
| 572 |
except Exception as exc:
|
| 573 |
latency_ms = int((time.perf_counter() - started) * 1000)
|
|
|
|
| 575 |
"ok": False,
|
| 576 |
"status": 0,
|
| 577 |
"latency_ms": latency_ms,
|
| 578 |
+
"error": _redact_url_credentials(str(exc) or exc.__class__.__name__),
|
| 579 |
+
**result_base,
|
| 580 |
}
|
| 581 |
finally:
|
| 582 |
session.close()
|
| 583 |
|
|
|
|
| 584 |
|
| 585 |
+
def test_clearance(target_url: str = "https://chatgpt.com") -> dict:
|
| 586 |
+
target_url = str(target_url or "https://chatgpt.com").strip() or "https://chatgpt.com"
|
| 587 |
+
started = time.perf_counter()
|
| 588 |
+
status = proxy_settings.get_runtime_status()
|
| 589 |
+
if not status.get("clearance_enabled"):
|
| 590 |
+
return {
|
| 591 |
+
"ok": False,
|
| 592 |
+
"status": "disabled",
|
| 593 |
+
"latency_ms": 0,
|
| 594 |
+
"has_cookies": False,
|
| 595 |
+
"user_agent": "",
|
| 596 |
+
"error": "clearance is disabled",
|
| 597 |
+
"runtime": status,
|
| 598 |
+
}
|
| 599 |
+
try:
|
| 600 |
+
bundle = proxy_settings.refresh_clearance(target_url=target_url, force=True, upstream=True)
|
| 601 |
+
except Exception as exc:
|
| 602 |
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
| 603 |
+
return {
|
| 604 |
+
"ok": False,
|
| 605 |
+
"status": "error",
|
| 606 |
+
"latency_ms": latency_ms,
|
| 607 |
+
"has_cookies": False,
|
| 608 |
+
"user_agent": "",
|
| 609 |
+
"error": _redact_url_credentials(str(exc) or exc.__class__.__name__),
|
| 610 |
+
"runtime": proxy_settings.get_runtime_status(),
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
| 614 |
+
runtime = proxy_settings.get_runtime_status()
|
| 615 |
+
if bundle is None:
|
| 616 |
+
return {
|
| 617 |
+
"ok": False,
|
| 618 |
+
"status": "failed",
|
| 619 |
+
"latency_ms": latency_ms,
|
| 620 |
+
"has_cookies": False,
|
| 621 |
+
"user_agent": "",
|
| 622 |
+
"error": "clearance refresh returned no bundle",
|
| 623 |
+
"runtime": runtime,
|
| 624 |
+
}
|
| 625 |
+
return {
|
| 626 |
+
"ok": True,
|
| 627 |
+
"status": "ok",
|
| 628 |
+
"latency_ms": latency_ms,
|
| 629 |
+
"has_cookies": bool(bundle.cookies),
|
| 630 |
+
"user_agent": bundle.user_agent or "",
|
| 631 |
+
"error": None,
|
| 632 |
+
"runtime": runtime,
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
proxy_settings = ProxySettingsStore()
|
services/register/openai_register.py
CHANGED
|
@@ -17,6 +17,7 @@ from urllib.parse import parse_qs, urlencode, urlparse
|
|
| 17 |
from curl_cffi import requests
|
| 18 |
|
| 19 |
from services.account_service import account_service
|
|
|
|
| 20 |
from services.register import mail_provider
|
| 21 |
|
| 22 |
base_dir = Path(__file__).resolve().parent
|
|
@@ -196,11 +197,22 @@ def _is_cloudflare_challenge(resp) -> bool:
|
|
| 196 |
text = str(getattr(resp, "text", "") or "").lower()
|
| 197 |
headers = getattr(resp, "headers", {}) or {}
|
| 198 |
server = str(headers.get("server") or "").lower()
|
| 199 |
-
|
| 200 |
-
"
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
or "<title>just a moment" in text
|
|
|
|
|
|
|
|
|
|
| 203 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
|
| 206 |
def _mail_config() -> dict:
|
|
@@ -246,12 +258,54 @@ def build_sentinel_token(session: requests.Session, device_id: str, flow: str) -
|
|
| 246 |
|
| 247 |
|
| 248 |
def create_session(proxy: str = "") -> Any:
|
| 249 |
-
kwargs =
|
| 250 |
-
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
| 252 |
return requests.Session(**kwargs)
|
| 253 |
|
| 254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
def request_with_local_retry(session: requests.Session, method: str, url: str, retry_attempts: int = 3, **kwargs):
|
| 256 |
last_error = ""
|
| 257 |
for _ in range(max(1, retry_attempts)):
|
|
@@ -329,7 +383,9 @@ def request_platform_oauth_token(session: requests.Session, code: str, code_veri
|
|
| 329 |
|
| 330 |
class PlatformRegistrar:
|
| 331 |
def __init__(self, proxy: str = "") -> None:
|
| 332 |
-
self.
|
|
|
|
|
|
|
| 333 |
self.device_id = str(uuid.uuid4())
|
| 334 |
self.code_verifier = ""
|
| 335 |
self.platform_auth_code = ""
|
|
@@ -350,6 +406,20 @@ class PlatformRegistrar:
|
|
| 350 |
headers.update(_make_trace_headers())
|
| 351 |
return headers
|
| 352 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
def _platform_authorize(self, email: str, index: int) -> None:
|
| 354 |
step(index, "开始 platform authorize")
|
| 355 |
self.session.cookies.set("oai-did", self.device_id, domain=".auth.openai.com")
|
|
@@ -376,12 +446,21 @@ class PlatformRegistrar:
|
|
| 376 |
"code_challenge_method": "S256",
|
| 377 |
"auth0Client": platform_auth0_client,
|
| 378 |
}
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
if resp is None or resp.status_code != 200:
|
| 381 |
err = _response_json(resp).get("error", {}) if resp is not None else {}
|
| 382 |
detail = f": {err.get('code', '')} - {err.get('message', '')}".strip(" -") if err else ""
|
| 383 |
-
if _is_cloudflare_challenge(resp):
|
| 384 |
-
raise RuntimeError("被 Cloudflare 拦截,请更换 IP 重试")
|
| 385 |
debug = _response_debug_detail(resp)
|
| 386 |
status = getattr(resp, "status_code", "unknown")
|
| 387 |
raise RuntimeError(error or f"platform_authorize_http_{status}{detail}, {debug}")
|
|
@@ -392,9 +471,21 @@ class PlatformRegistrar:
|
|
| 392 |
|
| 393 |
def _register_user(self, email: str, password: str, index: int) -> None:
|
| 394 |
step(index, "开始提交注册密码")
|
|
|
|
| 395 |
headers = self._json_headers(f"{auth_base}/create-account/password")
|
| 396 |
headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "username_password_create")
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
if resp is None or resp.status_code != 200:
|
| 399 |
data = _response_json(resp) if resp is not None else {}
|
| 400 |
if data.get("message") == "Failed to create account. Please try again.":
|
|
@@ -405,7 +496,17 @@ class PlatformRegistrar:
|
|
| 405 |
|
| 406 |
def _send_otp(self, index: int) -> None:
|
| 407 |
step(index, "开始发送验证码")
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
if resp is None or resp.status_code not in (200, 302):
|
| 410 |
raise RuntimeError(error or f"send_otp_http_{getattr(resp, 'status_code', 'unknown')}")
|
| 411 |
step(index, "发送验证码完成")
|
|
@@ -424,9 +525,21 @@ class PlatformRegistrar:
|
|
| 424 |
|
| 425 |
def _create_account(self, name: str, birthdate: str, index: int) -> None:
|
| 426 |
step(index, "开始创建账号资料")
|
|
|
|
| 427 |
headers = self._json_headers(f"{auth_base}/about-you")
|
| 428 |
headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "oauth_create_account")
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
if resp is None or resp.status_code not in (200, 302):
|
| 431 |
data = _response_json(resp) if resp is not None else {}
|
| 432 |
if data.get("message") == "Failed to create account. Please try again.":
|
|
|
|
| 17 |
from curl_cffi import requests
|
| 18 |
|
| 19 |
from services.account_service import account_service
|
| 20 |
+
from services.proxy_service import ClearanceBundle, proxy_settings
|
| 21 |
from services.register import mail_provider
|
| 22 |
|
| 23 |
base_dir = Path(__file__).resolve().parent
|
|
|
|
| 197 |
text = str(getattr(resp, "text", "") or "").lower()
|
| 198 |
headers = getattr(resp, "headers", {}) or {}
|
| 199 |
server = str(headers.get("server") or "").lower()
|
| 200 |
+
try:
|
| 201 |
+
status_code = int(getattr(resp, "status_code", 0) or 0)
|
| 202 |
+
except (TypeError, ValueError):
|
| 203 |
+
status_code = 0
|
| 204 |
+
|
| 205 |
+
explicit_challenge_markers = (
|
| 206 |
+
"challenges.cloudflare.com" in text
|
| 207 |
or "<title>just a moment" in text
|
| 208 |
+
or "/cdn-cgi/challenge-platform/" in text
|
| 209 |
+
or "cf-chl-" in text
|
| 210 |
+
or "__cf_chl_" in text
|
| 211 |
)
|
| 212 |
+
if explicit_challenge_markers:
|
| 213 |
+
return True
|
| 214 |
+
|
| 215 |
+
return "cloudflare" in server and status_code in (403, 503)
|
| 216 |
|
| 217 |
|
| 218 |
def _mail_config() -> dict:
|
|
|
|
| 258 |
|
| 259 |
|
| 260 |
def create_session(proxy: str = "") -> Any:
|
| 261 |
+
kwargs = proxy_settings.build_session_kwargs(
|
| 262 |
+
proxy=proxy,
|
| 263 |
+
upstream=True,
|
| 264 |
+
impersonate="chrome",
|
| 265 |
+
verify=False,
|
| 266 |
+
)
|
| 267 |
return requests.Session(**kwargs)
|
| 268 |
|
| 269 |
|
| 270 |
+
def _apply_clearance_to_session(session: requests.Session, bundle: ClearanceBundle | None) -> None:
|
| 271 |
+
if bundle is None:
|
| 272 |
+
return
|
| 273 |
+
if bundle.user_agent:
|
| 274 |
+
session.headers["User-Agent"] = bundle.user_agent
|
| 275 |
+
session.headers["user-agent"] = bundle.user_agent
|
| 276 |
+
for name, value in bundle.cookies.items():
|
| 277 |
+
try:
|
| 278 |
+
session.cookies.set(name, value, domain=f".{bundle.target_host or 'openai.com'}")
|
| 279 |
+
session.cookies.set(name, value, domain=bundle.target_host or "auth.openai.com")
|
| 280 |
+
except Exception:
|
| 281 |
+
continue
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _headers_with_clearance(
|
| 285 |
+
headers: dict[str, str],
|
| 286 |
+
target_url: str,
|
| 287 |
+
proxy: str = "",
|
| 288 |
+
user_agent_override: str = "",
|
| 289 |
+
) -> dict[str, str]:
|
| 290 |
+
merged = proxy_settings.build_headers(
|
| 291 |
+
headers=headers,
|
| 292 |
+
target_url=target_url,
|
| 293 |
+
proxy=proxy,
|
| 294 |
+
upstream=True,
|
| 295 |
+
)
|
| 296 |
+
normalized = {str(key): str(value) for key, value in merged.items()}
|
| 297 |
+
if user_agent_override:
|
| 298 |
+
ua_key = next((key for key in normalized if key.lower() == "user-agent"), "user-agent")
|
| 299 |
+
normalized[ua_key] = user_agent_override
|
| 300 |
+
return normalized
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _cloudflare_block_message(resp, prefix: str = "被 Cloudflare 拦截") -> str:
|
| 304 |
+
status = getattr(resp, "status_code", "unknown")
|
| 305 |
+
debug = _response_debug_detail(resp)
|
| 306 |
+
return f"{prefix},clearance 刷新失败或重试后仍失败,请更换 IP/代理重试: status={status}, {debug}"
|
| 307 |
+
|
| 308 |
+
|
| 309 |
def request_with_local_retry(session: requests.Session, method: str, url: str, retry_attempts: int = 3, **kwargs):
|
| 310 |
last_error = ""
|
| 311 |
for _ in range(max(1, retry_attempts)):
|
|
|
|
| 383 |
|
| 384 |
class PlatformRegistrar:
|
| 385 |
def __init__(self, proxy: str = "") -> None:
|
| 386 |
+
self.proxy = str(proxy or "").strip()
|
| 387 |
+
self.session = create_session(self.proxy)
|
| 388 |
+
self.clearance_user_agent = ""
|
| 389 |
self.device_id = str(uuid.uuid4())
|
| 390 |
self.code_verifier = ""
|
| 391 |
self.platform_auth_code = ""
|
|
|
|
| 406 |
headers.update(_make_trace_headers())
|
| 407 |
return headers
|
| 408 |
|
| 409 |
+
def _refresh_cloudflare_clearance(self, target_url: str, index: int) -> ClearanceBundle | None:
|
| 410 |
+
step(index, "检测到 Cloudflare 拦截,尝试刷新 clearance", "yellow")
|
| 411 |
+
bundle = proxy_settings.refresh_clearance(
|
| 412 |
+
target_url=target_url,
|
| 413 |
+
proxy=self.proxy,
|
| 414 |
+
force=True,
|
| 415 |
+
upstream=True,
|
| 416 |
+
)
|
| 417 |
+
if bundle is not None:
|
| 418 |
+
_apply_clearance_to_session(self.session, bundle)
|
| 419 |
+
self.clearance_user_agent = bundle.user_agent or self.clearance_user_agent
|
| 420 |
+
step(index, "Cloudflare clearance 刷新完成,重试当前请求", "yellow")
|
| 421 |
+
return bundle
|
| 422 |
+
|
| 423 |
def _platform_authorize(self, email: str, index: int) -> None:
|
| 424 |
step(index, "开始 platform authorize")
|
| 425 |
self.session.cookies.set("oai-did", self.device_id, domain=".auth.openai.com")
|
|
|
|
| 446 |
"code_challenge_method": "S256",
|
| 447 |
"auth0Client": platform_auth0_client,
|
| 448 |
}
|
| 449 |
+
target_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
|
| 450 |
+
headers = self._navigate_headers(f"{platform_base}/")
|
| 451 |
+
headers = _headers_with_clearance(headers, target_url, self.proxy, self.clearance_user_agent)
|
| 452 |
+
resp, error = request_with_local_retry(self.session, "get", target_url, headers=headers, allow_redirects=True, verify=False)
|
| 453 |
+
if _is_cloudflare_challenge(resp):
|
| 454 |
+
bundle = self._refresh_cloudflare_clearance(auth_base, index)
|
| 455 |
+
if bundle is None:
|
| 456 |
+
raise RuntimeError(_cloudflare_block_message(resp))
|
| 457 |
+
retry_headers = _headers_with_clearance(self._navigate_headers(f"{platform_base}/"), target_url, self.proxy, self.clearance_user_agent)
|
| 458 |
+
resp, error = request_with_local_retry(self.session, "get", target_url, headers=retry_headers, allow_redirects=True, verify=False)
|
| 459 |
+
if _is_cloudflare_challenge(resp):
|
| 460 |
+
raise RuntimeError(_cloudflare_block_message(resp, "Cloudflare clearance 重试仍被拦截"))
|
| 461 |
if resp is None or resp.status_code != 200:
|
| 462 |
err = _response_json(resp).get("error", {}) if resp is not None else {}
|
| 463 |
detail = f": {err.get('code', '')} - {err.get('message', '')}".strip(" -") if err else ""
|
|
|
|
|
|
|
| 464 |
debug = _response_debug_detail(resp)
|
| 465 |
status = getattr(resp, "status_code", "unknown")
|
| 466 |
raise RuntimeError(error or f"platform_authorize_http_{status}{detail}, {debug}")
|
|
|
|
| 471 |
|
| 472 |
def _register_user(self, email: str, password: str, index: int) -> None:
|
| 473 |
step(index, "开始提交注册密码")
|
| 474 |
+
url = f"{auth_base}/api/accounts/user/register"
|
| 475 |
headers = self._json_headers(f"{auth_base}/create-account/password")
|
| 476 |
headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "username_password_create")
|
| 477 |
+
headers = _headers_with_clearance(headers, url, self.proxy, self.clearance_user_agent)
|
| 478 |
+
resp, error = request_with_local_retry(self.session, "post", url, json={"username": email, "password": password}, headers=headers, verify=False)
|
| 479 |
+
if _is_cloudflare_challenge(resp):
|
| 480 |
+
bundle = self._refresh_cloudflare_clearance(auth_base, index)
|
| 481 |
+
if bundle is None:
|
| 482 |
+
raise RuntimeError(_cloudflare_block_message(resp))
|
| 483 |
+
headers = self._json_headers(f"{auth_base}/create-account/password")
|
| 484 |
+
headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "username_password_create")
|
| 485 |
+
headers = _headers_with_clearance(headers, url, self.proxy, self.clearance_user_agent)
|
| 486 |
+
resp, error = request_with_local_retry(self.session, "post", url, json={"username": email, "password": password}, headers=headers, verify=False)
|
| 487 |
+
if _is_cloudflare_challenge(resp):
|
| 488 |
+
raise RuntimeError(_cloudflare_block_message(resp, "Cloudflare clearance 重试仍被拦截"))
|
| 489 |
if resp is None or resp.status_code != 200:
|
| 490 |
data = _response_json(resp) if resp is not None else {}
|
| 491 |
if data.get("message") == "Failed to create account. Please try again.":
|
|
|
|
| 496 |
|
| 497 |
def _send_otp(self, index: int) -> None:
|
| 498 |
step(index, "开始发送验证码")
|
| 499 |
+
url = f"{auth_base}/api/accounts/email-otp/send"
|
| 500 |
+
headers = _headers_with_clearance(self._navigate_headers(f"{auth_base}/create-account/password"), url, self.proxy, self.clearance_user_agent)
|
| 501 |
+
resp, error = request_with_local_retry(self.session, "get", url, headers=headers, allow_redirects=True, verify=False)
|
| 502 |
+
if _is_cloudflare_challenge(resp):
|
| 503 |
+
bundle = self._refresh_cloudflare_clearance(auth_base, index)
|
| 504 |
+
if bundle is None:
|
| 505 |
+
raise RuntimeError(_cloudflare_block_message(resp))
|
| 506 |
+
headers = _headers_with_clearance(self._navigate_headers(f"{auth_base}/create-account/password"), url, self.proxy, self.clearance_user_agent)
|
| 507 |
+
resp, error = request_with_local_retry(self.session, "get", url, headers=headers, allow_redirects=True, verify=False)
|
| 508 |
+
if _is_cloudflare_challenge(resp):
|
| 509 |
+
raise RuntimeError(_cloudflare_block_message(resp, "Cloudflare clearance 重试仍被拦截"))
|
| 510 |
if resp is None or resp.status_code not in (200, 302):
|
| 511 |
raise RuntimeError(error or f"send_otp_http_{getattr(resp, 'status_code', 'unknown')}")
|
| 512 |
step(index, "发送验证码完成")
|
|
|
|
| 525 |
|
| 526 |
def _create_account(self, name: str, birthdate: str, index: int) -> None:
|
| 527 |
step(index, "开始创建账号资料")
|
| 528 |
+
url = f"{auth_base}/api/accounts/create_account"
|
| 529 |
headers = self._json_headers(f"{auth_base}/about-you")
|
| 530 |
headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "oauth_create_account")
|
| 531 |
+
headers = _headers_with_clearance(headers, url, self.proxy, self.clearance_user_agent)
|
| 532 |
+
resp, error = request_with_local_retry(self.session, "post", url, json={"name": name, "birthdate": birthdate}, headers=headers, verify=False)
|
| 533 |
+
if _is_cloudflare_challenge(resp):
|
| 534 |
+
bundle = self._refresh_cloudflare_clearance(auth_base, index)
|
| 535 |
+
if bundle is None:
|
| 536 |
+
raise RuntimeError(_cloudflare_block_message(resp))
|
| 537 |
+
headers = self._json_headers(f"{auth_base}/about-you")
|
| 538 |
+
headers["openai-sentinel-token"] = build_sentinel_token(self.session, self.device_id, "oauth_create_account")
|
| 539 |
+
headers = _headers_with_clearance(headers, url, self.proxy, self.clearance_user_agent)
|
| 540 |
+
resp, error = request_with_local_retry(self.session, "post", url, json={"name": name, "birthdate": birthdate}, headers=headers, verify=False)
|
| 541 |
+
if _is_cloudflare_challenge(resp):
|
| 542 |
+
raise RuntimeError(_cloudflare_block_message(resp, "Cloudflare clearance 重试仍被拦截"))
|
| 543 |
if resp is None or resp.status_code not in (200, 302):
|
| 544 |
data = _response_json(resp) if resp is not None else {}
|
| 545 |
if data.get("message") == "Failed to create account. Please try again.":
|
test/test_init_proxy_config.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import errno
|
| 2 |
+
import importlib.util
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
import unittest
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from unittest.mock import patch
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "init_proxy_config.py"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def load_script_module():
|
| 15 |
+
spec = importlib.util.spec_from_file_location("init_proxy_config_under_test", SCRIPT_PATH)
|
| 16 |
+
module = importlib.util.module_from_spec(spec)
|
| 17 |
+
assert spec is not None and spec.loader is not None
|
| 18 |
+
spec.loader.exec_module(module)
|
| 19 |
+
return module
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class InitProxyConfigTests(unittest.TestCase):
|
| 23 |
+
def test_creates_warp_defaults_when_proxy_runtime_missing(self) -> None:
|
| 24 |
+
module = load_script_module()
|
| 25 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 26 |
+
path = Path(tmp) / "config.json"
|
| 27 |
+
path.write_text(json.dumps({"auth-key": "secret", "proxy": ""}), encoding="utf-8")
|
| 28 |
+
with patch.dict(os.environ, {"CHATGPT2API_CONFIG_FILE": str(path)}, clear=False):
|
| 29 |
+
self.assertEqual(module.main(), 0)
|
| 30 |
+
|
| 31 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 32 |
+
self.assertEqual(data["auth-key"], "secret")
|
| 33 |
+
runtime = data["proxy_runtime"]
|
| 34 |
+
self.assertTrue(runtime["enabled"])
|
| 35 |
+
self.assertEqual(runtime["egress_mode"], "single_proxy")
|
| 36 |
+
self.assertEqual(runtime["proxy_url"], "http://privoxy:8118")
|
| 37 |
+
self.assertTrue(runtime["clearance"]["enabled"])
|
| 38 |
+
self.assertEqual(runtime["clearance"]["mode"], "flaresolverr")
|
| 39 |
+
self.assertEqual(runtime["clearance"]["flaresolverr_url"], "http://flaresolverr:8191")
|
| 40 |
+
|
| 41 |
+
def test_existing_custom_runtime_is_not_overwritten(self) -> None:
|
| 42 |
+
module = load_script_module()
|
| 43 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 44 |
+
path = Path(tmp) / "config.json"
|
| 45 |
+
path.write_text(
|
| 46 |
+
json.dumps(
|
| 47 |
+
{
|
| 48 |
+
"auth-key": "secret",
|
| 49 |
+
"proxy_runtime": {
|
| 50 |
+
"enabled": False,
|
| 51 |
+
"egress_mode": "single_proxy",
|
| 52 |
+
"proxy_url": "http://custom.proxy:8080",
|
| 53 |
+
"clearance": {
|
| 54 |
+
"enabled": True,
|
| 55 |
+
"mode": "manual",
|
| 56 |
+
"cf_clearance": "manual-token",
|
| 57 |
+
},
|
| 58 |
+
},
|
| 59 |
+
}
|
| 60 |
+
),
|
| 61 |
+
encoding="utf-8",
|
| 62 |
+
)
|
| 63 |
+
with patch.dict(os.environ, {"CHATGPT2API_CONFIG_FILE": str(path)}, clear=False):
|
| 64 |
+
self.assertEqual(module.main(), 0)
|
| 65 |
+
|
| 66 |
+
runtime = json.loads(path.read_text(encoding="utf-8"))["proxy_runtime"]
|
| 67 |
+
self.assertFalse(runtime["enabled"])
|
| 68 |
+
self.assertEqual(runtime["proxy_url"], "http://custom.proxy:8080")
|
| 69 |
+
self.assertEqual(runtime["clearance"]["mode"], "manual")
|
| 70 |
+
self.assertEqual(runtime["clearance"]["cf_clearance"], "manual-token")
|
| 71 |
+
self.assertIn("timeout_sec", runtime["clearance"])
|
| 72 |
+
self.assertIn("reset_session_status_codes", runtime)
|
| 73 |
+
|
| 74 |
+
def test_env_can_disable_runtime_defaults_for_warp_compose(self) -> None:
|
| 75 |
+
module = load_script_module()
|
| 76 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 77 |
+
path = Path(tmp) / "config.json"
|
| 78 |
+
path.write_text(json.dumps({"auth-key": "secret"}), encoding="utf-8")
|
| 79 |
+
with patch.dict(
|
| 80 |
+
os.environ,
|
| 81 |
+
{
|
| 82 |
+
"CHATGPT2API_CONFIG_FILE": str(path),
|
| 83 |
+
"CHATGPT2API_PROXY_RUNTIME_ENABLED": "false",
|
| 84 |
+
"CHATGPT2API_PROXY_RUNTIME_CLEARANCE_ENABLED": "false",
|
| 85 |
+
},
|
| 86 |
+
clear=False,
|
| 87 |
+
):
|
| 88 |
+
self.assertEqual(module.main(), 0)
|
| 89 |
+
|
| 90 |
+
runtime = json.loads(path.read_text(encoding="utf-8"))["proxy_runtime"]
|
| 91 |
+
self.assertFalse(runtime["enabled"])
|
| 92 |
+
self.assertFalse(runtime["clearance"]["enabled"])
|
| 93 |
+
self.assertEqual(runtime["clearance"]["mode"], "none")
|
| 94 |
+
|
| 95 |
+
def test_bind_mounted_config_file_falls_back_when_atomic_replace_is_busy(self) -> None:
|
| 96 |
+
module = load_script_module()
|
| 97 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 98 |
+
path = Path(tmp) / "config.json"
|
| 99 |
+
path.write_text(json.dumps({"auth-key": "secret", "proxy": ""}), encoding="utf-8")
|
| 100 |
+
original_replace = Path.replace
|
| 101 |
+
|
| 102 |
+
def replace_with_ebusy(self: Path, target: Path) -> Path:
|
| 103 |
+
if self.name == "config.json.tmp":
|
| 104 |
+
raise OSError(errno.EBUSY, "Device or resource busy")
|
| 105 |
+
return original_replace(self, target)
|
| 106 |
+
|
| 107 |
+
with patch.dict(os.environ, {"CHATGPT2API_CONFIG_FILE": str(path)}, clear=False):
|
| 108 |
+
with patch.object(Path, "replace", replace_with_ebusy):
|
| 109 |
+
self.assertEqual(module.main(), 0)
|
| 110 |
+
|
| 111 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 112 |
+
self.assertTrue(data["proxy_runtime"]["enabled"])
|
| 113 |
+
self.assertFalse(path.with_suffix(".json.tmp").exists())
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
unittest.main()
|
test/test_proxy_runtime_api.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import copy
|
| 4 |
+
import unittest
|
| 5 |
+
from unittest import mock
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI
|
| 8 |
+
from fastapi.testclient import TestClient
|
| 9 |
+
|
| 10 |
+
import api.system as system_module
|
| 11 |
+
from services.config import DEFAULT_PROXY_RUNTIME
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
AUTH_HEADERS = {"Authorization": "Bearer chatgpt2api"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FakeStorage:
|
| 18 |
+
def get_backend_info(self) -> dict[str, object]:
|
| 19 |
+
return {"type": "json"}
|
| 20 |
+
|
| 21 |
+
def health_check(self) -> dict[str, object]:
|
| 22 |
+
return {"ok": True}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class FakeConfig:
|
| 26 |
+
def __init__(self) -> None:
|
| 27 |
+
self.data: dict[str, object] = {
|
| 28 |
+
"proxy": "",
|
| 29 |
+
"proxy_runtime": copy.deepcopy(DEFAULT_PROXY_RUNTIME),
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
def get(self) -> dict[str, object]:
|
| 33 |
+
return copy.deepcopy(self.data)
|
| 34 |
+
|
| 35 |
+
def update(self, updates: dict[str, object]) -> dict[str, object]:
|
| 36 |
+
self.data.update(copy.deepcopy(updates))
|
| 37 |
+
return self.get()
|
| 38 |
+
|
| 39 |
+
def get_proxy_settings(self) -> str:
|
| 40 |
+
return str(self.data.get("proxy") or "").strip()
|
| 41 |
+
|
| 42 |
+
def get_proxy_runtime_settings(self) -> dict[str, object]:
|
| 43 |
+
return copy.deepcopy(self.data["proxy_runtime"]) # type: ignore[index]
|
| 44 |
+
|
| 45 |
+
def get_public_proxy_runtime_settings(self) -> dict[str, object]:
|
| 46 |
+
return self.get_proxy_runtime_settings()
|
| 47 |
+
|
| 48 |
+
def get_storage_backend(self) -> FakeStorage:
|
| 49 |
+
return FakeStorage()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class FakeProxySettings:
|
| 53 |
+
def get_runtime_status(self) -> dict[str, object]:
|
| 54 |
+
return {
|
| 55 |
+
"enabled": True,
|
| 56 |
+
"egress_mode": "single_proxy",
|
| 57 |
+
"proxy_source": "proxy_runtime",
|
| 58 |
+
"has_proxy": True,
|
| 59 |
+
"clearance_enabled": True,
|
| 60 |
+
"clearance_mode": "flaresolverr",
|
| 61 |
+
"has_clearance_bundle": False,
|
| 62 |
+
"cached_clearance_hosts": [],
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class FakeAccountService:
|
| 67 |
+
def get_stats(self) -> dict[str, object]:
|
| 68 |
+
return {
|
| 69 |
+
"total": 1,
|
| 70 |
+
"cumulative_total": 1,
|
| 71 |
+
"active": 1,
|
| 72 |
+
"unlimited_quota_count": 0,
|
| 73 |
+
"total_quota": 1,
|
| 74 |
+
"limited": 0,
|
| 75 |
+
"abnormal": 0,
|
| 76 |
+
"disabled": 0,
|
| 77 |
+
"total_success": 0,
|
| 78 |
+
"total_fail": 0,
|
| 79 |
+
"by_type": {"web": 1},
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class ProxyRuntimeApiTests(unittest.TestCase):
|
| 84 |
+
def setUp(self) -> None:
|
| 85 |
+
self.fake_config = FakeConfig()
|
| 86 |
+
self.fake_proxy_settings = FakeProxySettings()
|
| 87 |
+
self.test_proxy_calls: list[str] = []
|
| 88 |
+
self.test_clearance_calls: list[str] = []
|
| 89 |
+
|
| 90 |
+
def fake_test_proxy(url: str = "") -> dict[str, object]:
|
| 91 |
+
self.test_proxy_calls.append(url)
|
| 92 |
+
return {
|
| 93 |
+
"ok": True,
|
| 94 |
+
"status": 204,
|
| 95 |
+
"latency_ms": 12,
|
| 96 |
+
"error": None,
|
| 97 |
+
"proxy_source": "proxy_runtime" if not url else "input",
|
| 98 |
+
"has_proxy": True,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
def fake_test_clearance(target_url: str = "https://chatgpt.com") -> dict[str, object]:
|
| 102 |
+
self.test_clearance_calls.append(target_url)
|
| 103 |
+
return {
|
| 104 |
+
"ok": True,
|
| 105 |
+
"status": "ok",
|
| 106 |
+
"latency_ms": 34,
|
| 107 |
+
"has_cookies": True,
|
| 108 |
+
"user_agent": "Flare UA",
|
| 109 |
+
"error": None,
|
| 110 |
+
"runtime": self.fake_proxy_settings.get_runtime_status(),
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
self.patchers = [
|
| 114 |
+
mock.patch.object(system_module, "config", self.fake_config),
|
| 115 |
+
mock.patch.object(system_module, "require_admin", lambda _authorization: {"role": "admin"}),
|
| 116 |
+
mock.patch.object(system_module, "test_proxy", fake_test_proxy),
|
| 117 |
+
mock.patch.object(system_module, "test_clearance", fake_test_clearance, create=True),
|
| 118 |
+
mock.patch.object(system_module, "proxy_settings", self.fake_proxy_settings, create=True),
|
| 119 |
+
mock.patch("services.account_service.account_service", FakeAccountService()),
|
| 120 |
+
]
|
| 121 |
+
for patcher in self.patchers:
|
| 122 |
+
patcher.start()
|
| 123 |
+
self.addCleanup(patcher.stop)
|
| 124 |
+
app = FastAPI()
|
| 125 |
+
app.include_router(system_module.create_router("9.9.9-test"))
|
| 126 |
+
self.client = TestClient(app)
|
| 127 |
+
|
| 128 |
+
def test_proxy_test_can_use_active_runtime_when_url_is_empty(self) -> None:
|
| 129 |
+
response = self.client.post("/api/proxy/test", headers=AUTH_HEADERS, json={})
|
| 130 |
+
|
| 131 |
+
self.assertEqual(response.status_code, 200, response.text)
|
| 132 |
+
payload = response.json()["result"]
|
| 133 |
+
self.assertTrue(payload["ok"])
|
| 134 |
+
self.assertEqual(payload["proxy_source"], "proxy_runtime")
|
| 135 |
+
self.assertEqual(self.test_proxy_calls, [""])
|
| 136 |
+
|
| 137 |
+
def test_proxy_runtime_endpoint_reads_and_updates_runtime_config(self) -> None:
|
| 138 |
+
get_response = self.client.get("/api/proxy/runtime", headers=AUTH_HEADERS)
|
| 139 |
+
self.assertEqual(get_response.status_code, 200, get_response.text)
|
| 140 |
+
self.assertEqual(get_response.json()["runtime"]["enabled"], False)
|
| 141 |
+
self.assertEqual(get_response.json()["status"]["proxy_source"], "proxy_runtime")
|
| 142 |
+
|
| 143 |
+
runtime = copy.deepcopy(DEFAULT_PROXY_RUNTIME)
|
| 144 |
+
runtime["enabled"] = True
|
| 145 |
+
runtime["egress_mode"] = "single_proxy"
|
| 146 |
+
runtime["proxy_url"] = "http://privoxy:8118"
|
| 147 |
+
runtime["clearance"]["enabled"] = True # type: ignore[index]
|
| 148 |
+
runtime["clearance"]["mode"] = "flaresolverr" # type: ignore[index]
|
| 149 |
+
runtime["clearance"]["flaresolverr_url"] = "http://flaresolverr:8191" # type: ignore[index]
|
| 150 |
+
|
| 151 |
+
post_response = self.client.post("/api/proxy/runtime", headers=AUTH_HEADERS, json=runtime)
|
| 152 |
+
|
| 153 |
+
self.assertEqual(post_response.status_code, 200, post_response.text)
|
| 154 |
+
self.assertTrue(post_response.json()["runtime"]["enabled"])
|
| 155 |
+
self.assertEqual(self.fake_config.data["proxy_runtime"], runtime)
|
| 156 |
+
|
| 157 |
+
def test_clearance_test_endpoint_runs_clearance_refresh_without_returning_cookie_values(self) -> None:
|
| 158 |
+
response = self.client.post(
|
| 159 |
+
"/api/proxy/clearance/test",
|
| 160 |
+
headers=AUTH_HEADERS,
|
| 161 |
+
json={"target_url": "https://chatgpt.com/backend-api/models"},
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
self.assertEqual(response.status_code, 200, response.text)
|
| 165 |
+
payload = response.json()["result"]
|
| 166 |
+
self.assertTrue(payload["ok"])
|
| 167 |
+
self.assertTrue(payload["has_cookies"])
|
| 168 |
+
self.assertNotIn("cf_clearance", response.text)
|
| 169 |
+
self.assertEqual(self.test_clearance_calls, ["https://chatgpt.com/backend-api/models"])
|
| 170 |
+
|
| 171 |
+
def test_health_json_includes_proxy_runtime_status(self) -> None:
|
| 172 |
+
response = self.client.get("/health?format=json")
|
| 173 |
+
|
| 174 |
+
self.assertEqual(response.status_code, 200, response.text)
|
| 175 |
+
payload = response.json()
|
| 176 |
+
self.assertEqual(payload["version"], "9.9.9-test")
|
| 177 |
+
self.assertEqual(payload["proxy_runtime"]["clearance_mode"], "flaresolverr")
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
if __name__ == "__main__":
|
| 181 |
+
unittest.main()
|
test/test_proxy_runtime_config.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import json
|
| 3 |
+
import math
|
| 4 |
+
import tempfile
|
| 5 |
+
import unittest
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from services.config import (
|
| 9 |
+
DEFAULT_PROXY_RUNTIME,
|
| 10 |
+
ConfigStore,
|
| 11 |
+
_normalize_proxy_runtime_settings,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ProxyRuntimeConfigTests(unittest.TestCase):
|
| 16 |
+
def _make_store(self, initial: dict[str, object] | None = None) -> tuple[tempfile.TemporaryDirectory[str], ConfigStore]:
|
| 17 |
+
tmp_dir = tempfile.TemporaryDirectory()
|
| 18 |
+
path = Path(tmp_dir.name) / "config.json"
|
| 19 |
+
data = {"auth-key": "test-auth"}
|
| 20 |
+
if initial:
|
| 21 |
+
data.update(initial)
|
| 22 |
+
path.write_text(json.dumps(data), encoding="utf-8")
|
| 23 |
+
return tmp_dir, ConfigStore(path)
|
| 24 |
+
|
| 25 |
+
def test_defaults_are_safe_and_included_in_public_config(self) -> None:
|
| 26 |
+
tmp_dir, store = self._make_store({"proxy": " http://legacy.example:8080 "})
|
| 27 |
+
with tmp_dir:
|
| 28 |
+
expected_default = copy.deepcopy(DEFAULT_PROXY_RUNTIME)
|
| 29 |
+
runtime = store.get_proxy_runtime_settings()
|
| 30 |
+
self.assertEqual(runtime, expected_default)
|
| 31 |
+
self.assertEqual(store.get_proxy_settings(), "http://legacy.example:8080")
|
| 32 |
+
|
| 33 |
+
expected_public = copy.deepcopy(expected_default)
|
| 34 |
+
expected_public["clearance"]["has_cf_cookies"] = False
|
| 35 |
+
expected_public["clearance"]["has_cf_clearance"] = False
|
| 36 |
+
public_config = store.get()
|
| 37 |
+
self.assertEqual(public_config["proxy"], " http://legacy.example:8080 ")
|
| 38 |
+
self.assertEqual(public_config["proxy_runtime"], expected_public)
|
| 39 |
+
self.assertNotIn("auth-key", public_config)
|
| 40 |
+
|
| 41 |
+
runtime["enabled"] = True
|
| 42 |
+
runtime["clearance"]["enabled"] = True
|
| 43 |
+
self.assertEqual(DEFAULT_PROXY_RUNTIME, expected_default)
|
| 44 |
+
self.assertEqual(store.get_proxy_runtime_settings(), expected_default)
|
| 45 |
+
|
| 46 |
+
def test_normalize_proxy_runtime_sanitizes_invalid_values(self) -> None:
|
| 47 |
+
normalized = _normalize_proxy_runtime_settings(
|
| 48 |
+
{
|
| 49 |
+
"enabled": "yes",
|
| 50 |
+
"egress_mode": "tor",
|
| 51 |
+
"proxy_url": " http://proxy.example:8080 ",
|
| 52 |
+
"resource_proxy_url": " socks5://resource.example:1080 ",
|
| 53 |
+
"skip_ssl_verify": "on",
|
| 54 |
+
"reset_session_status_codes": ["403", 429, 99, 600, "bad", None, True],
|
| 55 |
+
"clearance": {
|
| 56 |
+
"enabled": "0",
|
| 57 |
+
"mode": "manual",
|
| 58 |
+
"cf_cookies": " a=b; c=d ",
|
| 59 |
+
"cf_clearance": " token ",
|
| 60 |
+
"user_agent": " Custom UA ",
|
| 61 |
+
"browser": " firefox ",
|
| 62 |
+
"flaresolverr_url": " http://flare.example/ ",
|
| 63 |
+
"timeout_sec": 0,
|
| 64 |
+
"refresh_interval": 59,
|
| 65 |
+
"warm_up_on_start": "true",
|
| 66 |
+
},
|
| 67 |
+
}
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
self.assertTrue(normalized["enabled"])
|
| 71 |
+
self.assertEqual(normalized["egress_mode"], "direct")
|
| 72 |
+
self.assertEqual(normalized["proxy_url"], "http://proxy.example:8080")
|
| 73 |
+
self.assertEqual(normalized["resource_proxy_url"], "socks5://resource.example:1080")
|
| 74 |
+
self.assertTrue(normalized["skip_ssl_verify"])
|
| 75 |
+
self.assertEqual(normalized["reset_session_status_codes"], [403, 429])
|
| 76 |
+
|
| 77 |
+
clearance = normalized["clearance"]
|
| 78 |
+
self.assertFalse(clearance["enabled"])
|
| 79 |
+
self.assertEqual(clearance["mode"], "manual")
|
| 80 |
+
self.assertEqual(clearance["cf_cookies"], "a=b; c=d")
|
| 81 |
+
self.assertEqual(clearance["cf_clearance"], "token")
|
| 82 |
+
self.assertEqual(clearance["user_agent"], "Custom UA")
|
| 83 |
+
self.assertEqual(clearance["browser"], "firefox")
|
| 84 |
+
self.assertEqual(clearance["flaresolverr_url"], "http://flare.example/")
|
| 85 |
+
self.assertEqual(clearance["timeout_sec"], 1)
|
| 86 |
+
self.assertEqual(clearance["refresh_interval"], 60)
|
| 87 |
+
self.assertTrue(clearance["warm_up_on_start"])
|
| 88 |
+
|
| 89 |
+
def test_normalize_proxy_runtime_uses_defaults_for_missing_or_empty_values(self) -> None:
|
| 90 |
+
self.assertEqual(_normalize_proxy_runtime_settings(None), DEFAULT_PROXY_RUNTIME)
|
| 91 |
+
self.assertEqual(
|
| 92 |
+
_normalize_proxy_runtime_settings(
|
| 93 |
+
{
|
| 94 |
+
"egress_mode": "single_proxy",
|
| 95 |
+
"reset_session_status_codes": ["bad", 99, 600],
|
| 96 |
+
"clearance": {
|
| 97 |
+
"enabled": True,
|
| 98 |
+
"mode": "invalid",
|
| 99 |
+
"user_agent": "",
|
| 100 |
+
"browser": "",
|
| 101 |
+
"timeout_sec": "bad",
|
| 102 |
+
"refresh_interval": "bad",
|
| 103 |
+
},
|
| 104 |
+
}
|
| 105 |
+
),
|
| 106 |
+
{
|
| 107 |
+
**DEFAULT_PROXY_RUNTIME,
|
| 108 |
+
"egress_mode": "single_proxy",
|
| 109 |
+
"reset_session_status_codes": [403],
|
| 110 |
+
"clearance": {
|
| 111 |
+
**DEFAULT_PROXY_RUNTIME["clearance"],
|
| 112 |
+
"enabled": True,
|
| 113 |
+
"mode": "none",
|
| 114 |
+
},
|
| 115 |
+
},
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
def test_malformed_proxy_runtime_values_fall_back_safely(self) -> None:
|
| 119 |
+
normalized = _normalize_proxy_runtime_settings(
|
| 120 |
+
{
|
| 121 |
+
"enabled": "maybe",
|
| 122 |
+
"skip_ssl_verify": "maybe",
|
| 123 |
+
"reset_session_status_codes": [math.inf, "NaN", "403"],
|
| 124 |
+
"clearance": {
|
| 125 |
+
"enabled": "maybe",
|
| 126 |
+
"timeout_sec": math.inf,
|
| 127 |
+
"refresh_interval": -math.inf,
|
| 128 |
+
"warm_up_on_start": "maybe",
|
| 129 |
+
},
|
| 130 |
+
}
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
self.assertFalse(normalized["enabled"])
|
| 134 |
+
self.assertFalse(normalized["skip_ssl_verify"])
|
| 135 |
+
self.assertEqual(normalized["reset_session_status_codes"], [403])
|
| 136 |
+
clearance = normalized["clearance"]
|
| 137 |
+
self.assertFalse(clearance["enabled"])
|
| 138 |
+
self.assertEqual(clearance["timeout_sec"], DEFAULT_PROXY_RUNTIME["clearance"]["timeout_sec"])
|
| 139 |
+
self.assertEqual(clearance["refresh_interval"], DEFAULT_PROXY_RUNTIME["clearance"]["refresh_interval"])
|
| 140 |
+
self.assertFalse(clearance["warm_up_on_start"])
|
| 141 |
+
|
| 142 |
+
def test_update_normalizes_and_persists_proxy_runtime(self) -> None:
|
| 143 |
+
tmp_dir, store = self._make_store()
|
| 144 |
+
with tmp_dir:
|
| 145 |
+
public_config = store.update(
|
| 146 |
+
{
|
| 147 |
+
"proxy_runtime": {
|
| 148 |
+
"enabled": "true",
|
| 149 |
+
"egress_mode": "single_proxy",
|
| 150 |
+
"proxy_url": " http://proxy.example ",
|
| 151 |
+
"reset_session_status_codes": [401, "403", "nope"],
|
| 152 |
+
"clearance": {
|
| 153 |
+
"enabled": "yes",
|
| 154 |
+
"mode": "flaresolverr",
|
| 155 |
+
"flaresolverr_url": " http://localhost:8191 ",
|
| 156 |
+
"timeout_sec": "30",
|
| 157 |
+
"refresh_interval": "120",
|
| 158 |
+
},
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
expected = {
|
| 164 |
+
**DEFAULT_PROXY_RUNTIME,
|
| 165 |
+
"enabled": True,
|
| 166 |
+
"egress_mode": "single_proxy",
|
| 167 |
+
"proxy_url": "http://proxy.example",
|
| 168 |
+
"reset_session_status_codes": [401, 403],
|
| 169 |
+
"clearance": {
|
| 170 |
+
**DEFAULT_PROXY_RUNTIME["clearance"],
|
| 171 |
+
"enabled": True,
|
| 172 |
+
"mode": "flaresolverr",
|
| 173 |
+
"flaresolverr_url": "http://localhost:8191",
|
| 174 |
+
"timeout_sec": 30,
|
| 175 |
+
"refresh_interval": 120,
|
| 176 |
+
},
|
| 177 |
+
}
|
| 178 |
+
expected_public = copy.deepcopy(expected)
|
| 179 |
+
expected_public["clearance"]["cf_cookies"] = ""
|
| 180 |
+
expected_public["clearance"]["cf_clearance"] = ""
|
| 181 |
+
expected_public["clearance"]["has_cf_cookies"] = False
|
| 182 |
+
expected_public["clearance"]["has_cf_clearance"] = False
|
| 183 |
+
self.assertEqual(public_config["proxy_runtime"], expected_public)
|
| 184 |
+
|
| 185 |
+
raw_saved = json.loads(store.path.read_text(encoding="utf-8"))
|
| 186 |
+
self.assertEqual(raw_saved["proxy_runtime"], expected)
|
| 187 |
+
reloaded = ConfigStore(store.path)
|
| 188 |
+
self.assertEqual(reloaded.get_proxy_runtime_settings(), expected)
|
| 189 |
+
|
| 190 |
+
def test_public_proxy_runtime_redacts_and_preserves_existing_clearance_values(self) -> None:
|
| 191 |
+
existing = copy.deepcopy(DEFAULT_PROXY_RUNTIME)
|
| 192 |
+
existing["enabled"] = True
|
| 193 |
+
existing["clearance"]["enabled"] = True
|
| 194 |
+
existing["clearance"]["mode"] = "manual"
|
| 195 |
+
existing["clearance"]["cf_cookies"] = "session=secret-cookie"
|
| 196 |
+
existing["clearance"]["cf_clearance"] = "secret-clearance"
|
| 197 |
+
tmp_dir, store = self._make_store({"proxy_runtime": existing})
|
| 198 |
+
with tmp_dir:
|
| 199 |
+
public_config = store.get()
|
| 200 |
+
public_runtime = public_config["proxy_runtime"]
|
| 201 |
+
public_clearance = public_runtime["clearance"]
|
| 202 |
+
self.assertEqual(public_clearance["cf_cookies"], "")
|
| 203 |
+
self.assertEqual(public_clearance["cf_clearance"], "")
|
| 204 |
+
self.assertTrue(public_clearance["has_cf_cookies"])
|
| 205 |
+
self.assertTrue(public_clearance["has_cf_clearance"])
|
| 206 |
+
self.assertNotIn("secret-cookie", json.dumps(public_config))
|
| 207 |
+
self.assertNotIn("secret-clearance", json.dumps(public_config))
|
| 208 |
+
|
| 209 |
+
public_clearance["user_agent"] = "Updated UA"
|
| 210 |
+
updated_public = store.update({"proxy_runtime": public_runtime})
|
| 211 |
+
updated_raw = json.loads(store.path.read_text(encoding="utf-8"))["proxy_runtime"]
|
| 212 |
+
self.assertEqual(updated_raw["clearance"]["cf_cookies"], "session=secret-cookie")
|
| 213 |
+
self.assertEqual(updated_raw["clearance"]["cf_clearance"], "secret-clearance")
|
| 214 |
+
self.assertEqual(updated_raw["clearance"]["user_agent"], "Updated UA")
|
| 215 |
+
self.assertEqual(updated_public["proxy_runtime"]["clearance"]["cf_cookies"], "")
|
| 216 |
+
self.assertEqual(updated_public["proxy_runtime"]["clearance"]["cf_clearance"], "")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
if __name__ == "__main__":
|
| 220 |
+
unittest.main()
|
test/test_proxy_service.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import json
|
| 3 |
+
import threading
|
| 4 |
+
import time
|
| 5 |
+
import unittest
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 7 |
+
from unittest.mock import patch
|
| 8 |
+
from urllib.parse import urlparse
|
| 9 |
+
|
| 10 |
+
from services.config import DEFAULT_PROXY_RUNTIME
|
| 11 |
+
from services.proxy_service import (
|
| 12 |
+
ClearanceBundle,
|
| 13 |
+
FlareSolverrClearanceProvider,
|
| 14 |
+
ProxySettingsStore,
|
| 15 |
+
normalize_proxy_url,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FakeConfig:
|
| 20 |
+
def __init__(self, legacy_proxy: str = "", runtime: dict[str, object] | None = None) -> None:
|
| 21 |
+
self.legacy_proxy = legacy_proxy
|
| 22 |
+
self.runtime = runtime if runtime is not None else copy.deepcopy(DEFAULT_PROXY_RUNTIME)
|
| 23 |
+
|
| 24 |
+
def get_proxy_settings(self) -> str:
|
| 25 |
+
return self.legacy_proxy
|
| 26 |
+
|
| 27 |
+
def get_proxy_runtime_settings(self) -> dict[str, object]:
|
| 28 |
+
return copy.deepcopy(self.runtime)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def make_runtime(**overrides: object) -> dict[str, object]:
|
| 32 |
+
runtime = copy.deepcopy(DEFAULT_PROXY_RUNTIME)
|
| 33 |
+
clearance = overrides.pop("clearance", None)
|
| 34 |
+
runtime.update(overrides)
|
| 35 |
+
if clearance is not None:
|
| 36 |
+
runtime["clearance"].update(clearance) # type: ignore[index,union-attr]
|
| 37 |
+
return runtime
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ProxyServiceTests(unittest.TestCase):
|
| 41 |
+
def test_normalize_proxy_url_strips_and_converts_socks_schemes(self) -> None:
|
| 42 |
+
self.assertEqual(normalize_proxy_url(" http://proxy.example:8080 "), "http://proxy.example:8080")
|
| 43 |
+
self.assertEqual(normalize_proxy_url("\thttps://proxy.example:8443\n"), "https://proxy.example:8443")
|
| 44 |
+
self.assertEqual(normalize_proxy_url(" socks://proxy.example:1080 "), "socks5h://proxy.example:1080")
|
| 45 |
+
self.assertEqual(normalize_proxy_url("socks5://proxy.example:1080"), "socks5h://proxy.example:1080")
|
| 46 |
+
self.assertEqual(normalize_proxy_url(" socks5h://proxy.example:1080 "), "socks5h://proxy.example:1080")
|
| 47 |
+
self.assertEqual(normalize_proxy_url(" "), "")
|
| 48 |
+
|
| 49 |
+
def test_build_session_kwargs_keeps_legacy_global_proxy_when_runtime_disabled(self) -> None:
|
| 50 |
+
store = ProxySettingsStore(FakeConfig(legacy_proxy=" http://legacy.example:8080 "))
|
| 51 |
+
|
| 52 |
+
kwargs = store.build_session_kwargs(impersonate="chrome")
|
| 53 |
+
|
| 54 |
+
self.assertEqual(kwargs["impersonate"], "chrome")
|
| 55 |
+
self.assertEqual(kwargs["proxy"], "http://legacy.example:8080")
|
| 56 |
+
|
| 57 |
+
def test_runtime_proxy_is_limited_to_upstream_scope_by_default(self) -> None:
|
| 58 |
+
runtime = make_runtime(enabled=True, egress_mode="single_proxy", proxy_url="http://runtime.example:8080")
|
| 59 |
+
store = ProxySettingsStore(FakeConfig(legacy_proxy="http://legacy.example:8080", runtime=runtime))
|
| 60 |
+
|
| 61 |
+
self.assertEqual(
|
| 62 |
+
store.build_session_kwargs()["proxy"],
|
| 63 |
+
"http://legacy.example:8080",
|
| 64 |
+
)
|
| 65 |
+
self.assertEqual(
|
| 66 |
+
store.build_session_kwargs(upstream=True)["proxy"],
|
| 67 |
+
"http://runtime.example:8080",
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
def test_account_proxy_wins_over_runtime_and_global_proxy(self) -> None:
|
| 71 |
+
runtime = make_runtime(enabled=True, egress_mode="single_proxy", proxy_url="http://runtime.example:8080")
|
| 72 |
+
store = ProxySettingsStore(FakeConfig(legacy_proxy="http://legacy.example:8080", runtime=runtime))
|
| 73 |
+
|
| 74 |
+
kwargs = store.build_session_kwargs(account={"proxy": " socks://account.example:1080 "}, upstream=True)
|
| 75 |
+
|
| 76 |
+
self.assertEqual(kwargs["proxy"], "socks5h://account.example:1080")
|
| 77 |
+
|
| 78 |
+
def test_proxy_runtime_single_proxy_wins_over_explicit_and_legacy_proxy_when_enabled(self) -> None:
|
| 79 |
+
runtime = make_runtime(enabled=True, egress_mode="single_proxy", proxy_url=" socks5://runtime.example:1080 ")
|
| 80 |
+
store = ProxySettingsStore(FakeConfig(legacy_proxy="http://legacy.example:8080", runtime=runtime))
|
| 81 |
+
|
| 82 |
+
kwargs = store.build_session_kwargs(proxy="http://explicit.example:8080", upstream=True)
|
| 83 |
+
|
| 84 |
+
self.assertEqual(kwargs["proxy"], "socks5h://runtime.example:1080")
|
| 85 |
+
|
| 86 |
+
def test_explicit_proxy_wins_over_legacy_global_proxy_when_runtime_disabled(self) -> None:
|
| 87 |
+
store = ProxySettingsStore(FakeConfig(legacy_proxy="http://legacy.example:8080"))
|
| 88 |
+
|
| 89 |
+
kwargs = store.build_session_kwargs(proxy=" socks5://explicit.example:1080 ")
|
| 90 |
+
|
| 91 |
+
self.assertEqual(kwargs["proxy"], "socks5h://explicit.example:1080")
|
| 92 |
+
|
| 93 |
+
def test_resource_requests_use_resource_proxy_url_when_configured(self) -> None:
|
| 94 |
+
runtime = make_runtime(
|
| 95 |
+
enabled=True,
|
| 96 |
+
egress_mode="single_proxy",
|
| 97 |
+
proxy_url="http://runtime.example:8080",
|
| 98 |
+
resource_proxy_url=" socks5://resource.example:1080 ",
|
| 99 |
+
)
|
| 100 |
+
store = ProxySettingsStore(FakeConfig(legacy_proxy="http://legacy.example:8080", runtime=runtime))
|
| 101 |
+
|
| 102 |
+
kwargs = store.build_session_kwargs(resource=True, upstream=True)
|
| 103 |
+
|
| 104 |
+
self.assertEqual(kwargs["proxy"], "socks5h://resource.example:1080")
|
| 105 |
+
|
| 106 |
+
def test_manual_clearance_merges_cookies_and_preserves_explicit_user_agent(self) -> None:
|
| 107 |
+
runtime = make_runtime(
|
| 108 |
+
enabled=True,
|
| 109 |
+
egress_mode="single_proxy",
|
| 110 |
+
proxy_url="http://runtime.example:8080",
|
| 111 |
+
clearance={
|
| 112 |
+
"enabled": True,
|
| 113 |
+
"mode": "manual",
|
| 114 |
+
"cf_cookies": "foo=bar; session=ok",
|
| 115 |
+
"cf_clearance": "manual-token",
|
| 116 |
+
"user_agent": "Manual UA",
|
| 117 |
+
},
|
| 118 |
+
)
|
| 119 |
+
store = ProxySettingsStore(FakeConfig(runtime=runtime))
|
| 120 |
+
|
| 121 |
+
headers = store.build_headers(headers={"Cookie": "existing=1"}, target_url="https://chatgpt.com/backend-api")
|
| 122 |
+
|
| 123 |
+
self.assertEqual(headers["User-Agent"], "Manual UA")
|
| 124 |
+
self.assertEqual(headers["Cookie"], "existing=1; foo=bar; session=ok; cf_clearance=manual-token")
|
| 125 |
+
|
| 126 |
+
headers_with_ua = store.build_headers(
|
| 127 |
+
headers={"User-Agent": "Caller UA", "Cookie": "cf_clearance=caller-token"},
|
| 128 |
+
target_url="https://chatgpt.com/backend-api",
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
self.assertEqual(headers_with_ua["User-Agent"], "Caller UA")
|
| 132 |
+
self.assertEqual(headers_with_ua["Cookie"], "cf_clearance=caller-token; foo=bar; session=ok")
|
| 133 |
+
self.assertNotIn("cf_clearance=manual-token", headers_with_ua["Cookie"])
|
| 134 |
+
|
| 135 |
+
def test_flaresolverr_provider_parses_solution_and_filters_cookies_by_host(self) -> None:
|
| 136 |
+
calls: list[tuple[str, dict[str, object], dict[str, str], float]] = []
|
| 137 |
+
|
| 138 |
+
def fake_request(endpoint: str, body: bytes, headers: dict[str, str], timeout: float) -> bytes:
|
| 139 |
+
payload = json.loads(body.decode("utf-8"))
|
| 140 |
+
calls.append((endpoint, payload, headers, timeout))
|
| 141 |
+
return json.dumps(
|
| 142 |
+
{
|
| 143 |
+
"status": "ok",
|
| 144 |
+
"solution": {
|
| 145 |
+
"userAgent": "Flare UA",
|
| 146 |
+
"cookies": [
|
| 147 |
+
{"name": "cf_clearance", "value": "host-token", "domain": ".chatgpt.com"},
|
| 148 |
+
{"name": "no_domain", "value": "kept"},
|
| 149 |
+
{"name": "wrong_host", "value": "dropped", "domain": "example.net"},
|
| 150 |
+
],
|
| 151 |
+
},
|
| 152 |
+
}
|
| 153 |
+
).encode("utf-8")
|
| 154 |
+
|
| 155 |
+
provider = FlareSolverrClearanceProvider("http://flare.local/", request_method=fake_request)
|
| 156 |
+
|
| 157 |
+
bundle = provider.get_clearance(
|
| 158 |
+
"https://chatgpt.com/backend-api/conversation",
|
| 159 |
+
proxy_url="socks5h://proxy.example:1080",
|
| 160 |
+
timeout_sec=12,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
self.assertIsNotNone(bundle)
|
| 164 |
+
assert bundle is not None
|
| 165 |
+
endpoint, payload, headers, timeout = calls[0]
|
| 166 |
+
self.assertEqual(endpoint, "http://flare.local/v1")
|
| 167 |
+
self.assertEqual(payload["cmd"], "request.get")
|
| 168 |
+
self.assertEqual(payload["url"], "https://chatgpt.com/backend-api/conversation")
|
| 169 |
+
self.assertEqual(payload["maxTimeout"], 12000)
|
| 170 |
+
self.assertEqual(payload["proxy"], {"url": "socks5h://proxy.example:1080"})
|
| 171 |
+
self.assertEqual(headers["Content-Type"], "application/json")
|
| 172 |
+
self.assertEqual(timeout, 12)
|
| 173 |
+
self.assertEqual(bundle.user_agent, "Flare UA")
|
| 174 |
+
self.assertEqual(bundle.cookies, {"cf_clearance": "host-token", "no_domain": "kept"})
|
| 175 |
+
|
| 176 |
+
def test_flaresolverr_provider_keeps_only_matching_or_no_domain_cookies(self) -> None:
|
| 177 |
+
def fake_request(endpoint: str, body: bytes, headers: dict[str, str], timeout: float) -> bytes:
|
| 178 |
+
return json.dumps(
|
| 179 |
+
{
|
| 180 |
+
"status": "ok",
|
| 181 |
+
"solution": {
|
| 182 |
+
"userAgent": "Filtered UA",
|
| 183 |
+
"cookies": [
|
| 184 |
+
{"name": "wrong_host", "value": "dropped", "domain": "example.net"},
|
| 185 |
+
{"name": "also_wrong", "value": "dropped-too", "domain": ".example.org"},
|
| 186 |
+
{"name": "no_domain", "value": "kept"},
|
| 187 |
+
],
|
| 188 |
+
},
|
| 189 |
+
}
|
| 190 |
+
).encode("utf-8")
|
| 191 |
+
|
| 192 |
+
provider = FlareSolverrClearanceProvider("http://flare.local", request_method=fake_request)
|
| 193 |
+
|
| 194 |
+
bundle = provider.get_clearance("https://chatgpt.com", timeout_sec=5)
|
| 195 |
+
|
| 196 |
+
self.assertIsNotNone(bundle)
|
| 197 |
+
assert bundle is not None
|
| 198 |
+
self.assertEqual(bundle.cookies, {"no_domain": "kept"})
|
| 199 |
+
self.assertEqual(bundle.user_agent, "Filtered UA")
|
| 200 |
+
|
| 201 |
+
def test_flaresolverr_provider_drops_all_wrong_domain_cookies(self) -> None:
|
| 202 |
+
def fake_request(endpoint: str, body: bytes, headers: dict[str, str], timeout: float) -> bytes:
|
| 203 |
+
return json.dumps(
|
| 204 |
+
{
|
| 205 |
+
"status": "ok",
|
| 206 |
+
"solution": {
|
| 207 |
+
"cookies": [
|
| 208 |
+
{"name": "wrong_host", "value": "dropped", "domain": "example.net"},
|
| 209 |
+
],
|
| 210 |
+
},
|
| 211 |
+
}
|
| 212 |
+
).encode("utf-8")
|
| 213 |
+
|
| 214 |
+
provider = FlareSolverrClearanceProvider("http://flare.local", request_method=fake_request)
|
| 215 |
+
|
| 216 |
+
self.assertIsNone(provider.get_clearance("https://chatgpt.com", timeout_sec=5))
|
| 217 |
+
|
| 218 |
+
def test_cached_flaresolverr_bundle_is_merged_by_build_headers_and_can_be_invalidated(self) -> None:
|
| 219 |
+
runtime = make_runtime(
|
| 220 |
+
enabled=True,
|
| 221 |
+
egress_mode="single_proxy",
|
| 222 |
+
proxy_url="http://runtime.example:8080",
|
| 223 |
+
clearance={
|
| 224 |
+
"enabled": True,
|
| 225 |
+
"mode": "flaresolverr",
|
| 226 |
+
"flaresolverr_url": "http://flare.local",
|
| 227 |
+
"timeout_sec": 5,
|
| 228 |
+
},
|
| 229 |
+
)
|
| 230 |
+
bundle = ClearanceBundle(
|
| 231 |
+
target_host="chatgpt.com",
|
| 232 |
+
proxy_url="http://runtime.example:8080",
|
| 233 |
+
cookies={"cf_clearance": "flare-token"},
|
| 234 |
+
user_agent="Flare UA",
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
class FakeProvider:
|
| 238 |
+
def get_clearance(self, target_url: str, proxy_url: str = "", timeout_sec: int = 60) -> ClearanceBundle | None:
|
| 239 |
+
return bundle
|
| 240 |
+
|
| 241 |
+
store = ProxySettingsStore(FakeConfig(runtime=runtime), clearance_provider_factory=lambda _url: FakeProvider())
|
| 242 |
+
store.refresh_clearance(target_url="https://chatgpt.com", force=True)
|
| 243 |
+
|
| 244 |
+
headers = store.build_headers(headers={"Cookie": "existing=1"}, target_url="https://chatgpt.com/backend-api")
|
| 245 |
+
|
| 246 |
+
self.assertEqual(headers["User-Agent"], "Flare UA")
|
| 247 |
+
self.assertEqual(headers["Cookie"], "existing=1; cf_clearance=flare-token")
|
| 248 |
+
|
| 249 |
+
store.invalidate_clearance(target_url="https://chatgpt.com")
|
| 250 |
+
self.assertEqual(
|
| 251 |
+
store.build_headers(headers={"Cookie": "existing=1"}, target_url="https://chatgpt.com/backend-api"),
|
| 252 |
+
{"Cookie": "existing=1"},
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
def test_flaresolverr_refresh_failure_keeps_old_cached_bundle(self) -> None:
|
| 256 |
+
runtime = make_runtime(
|
| 257 |
+
enabled=True,
|
| 258 |
+
egress_mode="single_proxy",
|
| 259 |
+
proxy_url="http://runtime.example:8080",
|
| 260 |
+
clearance={
|
| 261 |
+
"enabled": True,
|
| 262 |
+
"mode": "flaresolverr",
|
| 263 |
+
"flaresolverr_url": "http://flare.local",
|
| 264 |
+
"timeout_sec": 5,
|
| 265 |
+
},
|
| 266 |
+
)
|
| 267 |
+
first_bundle = ClearanceBundle(
|
| 268 |
+
target_host="chatgpt.com",
|
| 269 |
+
proxy_url="http://runtime.example:8080",
|
| 270 |
+
cookies={"cf_clearance": "old-token"},
|
| 271 |
+
user_agent="Old UA",
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
class FakeProvider:
|
| 275 |
+
def __init__(self) -> None:
|
| 276 |
+
self.calls = 0
|
| 277 |
+
|
| 278 |
+
def get_clearance(self, target_url: str, proxy_url: str = "", timeout_sec: int = 60) -> ClearanceBundle | None:
|
| 279 |
+
self.calls += 1
|
| 280 |
+
return first_bundle if self.calls == 1 else None
|
| 281 |
+
|
| 282 |
+
provider = FakeProvider()
|
| 283 |
+
store = ProxySettingsStore(FakeConfig(runtime=runtime), clearance_provider_factory=lambda _url: provider)
|
| 284 |
+
|
| 285 |
+
refreshed = store.refresh_clearance(target_url="https://chatgpt.com", force=True)
|
| 286 |
+
fallback = store.refresh_clearance(target_url="https://chatgpt.com", force=True)
|
| 287 |
+
|
| 288 |
+
self.assertIsNotNone(refreshed)
|
| 289 |
+
assert refreshed is not None
|
| 290 |
+
self.assertEqual(refreshed.cookies, first_bundle.cookies)
|
| 291 |
+
self.assertIsNotNone(refreshed.expires_at)
|
| 292 |
+
assert refreshed.expires_at is not None
|
| 293 |
+
self.assertAlmostEqual(refreshed.expires_at - refreshed.created_at, 3600, places=2)
|
| 294 |
+
self.assertIs(fallback, refreshed)
|
| 295 |
+
self.assertEqual(provider.calls, 2)
|
| 296 |
+
|
| 297 |
+
def test_flaresolverr_bundle_expires_after_refresh_interval(self) -> None:
|
| 298 |
+
runtime = make_runtime(
|
| 299 |
+
enabled=True,
|
| 300 |
+
egress_mode="single_proxy",
|
| 301 |
+
proxy_url="http://runtime.example:8080",
|
| 302 |
+
clearance={
|
| 303 |
+
"enabled": True,
|
| 304 |
+
"mode": "flaresolverr",
|
| 305 |
+
"flaresolverr_url": "http://flare.local",
|
| 306 |
+
"timeout_sec": 5,
|
| 307 |
+
"refresh_interval": 60,
|
| 308 |
+
},
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
class FakeProvider:
|
| 312 |
+
def get_clearance(self, target_url: str, proxy_url: str = "", timeout_sec: int = 60) -> ClearanceBundle | None:
|
| 313 |
+
return ClearanceBundle(
|
| 314 |
+
target_host="chatgpt.com",
|
| 315 |
+
proxy_url=proxy_url,
|
| 316 |
+
cookies={"cf_clearance": "expiring-token"},
|
| 317 |
+
user_agent="Expiring UA",
|
| 318 |
+
created_at=1000,
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
store = ProxySettingsStore(FakeConfig(runtime=runtime), clearance_provider_factory=lambda _url: FakeProvider())
|
| 322 |
+
with patch("services.proxy_service.time.time", return_value=1000):
|
| 323 |
+
bundle = store.refresh_clearance(target_url="https://chatgpt.com", force=True)
|
| 324 |
+
|
| 325 |
+
self.assertIsNotNone(bundle)
|
| 326 |
+
assert bundle is not None
|
| 327 |
+
self.assertEqual(bundle.expires_at, 1060)
|
| 328 |
+
self.assertTrue(bundle.is_valid_for("chatgpt.com", "http://runtime.example:8080", now=1059))
|
| 329 |
+
self.assertFalse(bundle.is_valid_for("chatgpt.com", "http://runtime.example:8080", now=1060))
|
| 330 |
+
|
| 331 |
+
def test_profile_repr_does_not_expose_clearance_secrets(self) -> None:
|
| 332 |
+
runtime = make_runtime(
|
| 333 |
+
enabled=True,
|
| 334 |
+
clearance={
|
| 335 |
+
"enabled": True,
|
| 336 |
+
"mode": "manual",
|
| 337 |
+
"cf_cookies": "foo=secret-cookie",
|
| 338 |
+
"cf_clearance": "secret-clearance",
|
| 339 |
+
},
|
| 340 |
+
)
|
| 341 |
+
profile = ProxySettingsStore(FakeConfig(runtime=runtime)).get_profile()
|
| 342 |
+
|
| 343 |
+
text = repr(profile)
|
| 344 |
+
|
| 345 |
+
self.assertNotIn("secret-cookie", text)
|
| 346 |
+
self.assertNotIn("secret-clearance", text)
|
| 347 |
+
|
| 348 |
+
def test_proxy_test_error_redacts_proxy_credentials(self) -> None:
|
| 349 |
+
class FailingSession:
|
| 350 |
+
def __init__(self, **kwargs: object) -> None:
|
| 351 |
+
pass
|
| 352 |
+
|
| 353 |
+
def get(self, *args: object, **kwargs: object) -> object:
|
| 354 |
+
raise RuntimeError("proxy failed for http://user:pass@proxy.example:8080")
|
| 355 |
+
|
| 356 |
+
def close(self) -> None:
|
| 357 |
+
pass
|
| 358 |
+
|
| 359 |
+
with patch("services.proxy_service.Session", FailingSession):
|
| 360 |
+
result = __import__("services.proxy_service", fromlist=["test_proxy"]).test_proxy(
|
| 361 |
+
"http://user:pass@proxy.example:8080"
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
self.assertFalse(result["ok"])
|
| 365 |
+
self.assertIn("[REDACTED]", result["error"])
|
| 366 |
+
self.assertNotIn("user:pass", result["error"])
|
| 367 |
+
|
| 368 |
+
def test_concurrent_flaresolverr_refresh_uses_single_flight_per_proxy_and_host(self) -> None:
|
| 369 |
+
runtime = make_runtime(
|
| 370 |
+
enabled=True,
|
| 371 |
+
egress_mode="single_proxy",
|
| 372 |
+
proxy_url="http://runtime.example:8080",
|
| 373 |
+
clearance={
|
| 374 |
+
"enabled": True,
|
| 375 |
+
"mode": "flaresolverr",
|
| 376 |
+
"flaresolverr_url": "http://flare.local",
|
| 377 |
+
"timeout_sec": 5,
|
| 378 |
+
},
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
class SlowProvider:
|
| 382 |
+
def __init__(self) -> None:
|
| 383 |
+
self.calls = 0
|
| 384 |
+
self.lock = threading.Lock()
|
| 385 |
+
|
| 386 |
+
def get_clearance(self, target_url: str, proxy_url: str = "", timeout_sec: int = 60) -> ClearanceBundle | None:
|
| 387 |
+
with self.lock:
|
| 388 |
+
self.calls += 1
|
| 389 |
+
time.sleep(0.15)
|
| 390 |
+
return ClearanceBundle(
|
| 391 |
+
target_host=urlparse(target_url).hostname or "",
|
| 392 |
+
proxy_url=proxy_url,
|
| 393 |
+
cookies={"cf_clearance": "thread-token"},
|
| 394 |
+
user_agent="Thread UA",
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
provider = SlowProvider()
|
| 398 |
+
store = ProxySettingsStore(FakeConfig(runtime=runtime), clearance_provider_factory=lambda _url: provider)
|
| 399 |
+
workers = 6
|
| 400 |
+
start = threading.Barrier(workers)
|
| 401 |
+
|
| 402 |
+
def refresh() -> ClearanceBundle | None:
|
| 403 |
+
start.wait(timeout=5)
|
| 404 |
+
return store.refresh_clearance(target_url="https://chatgpt.com", force=True)
|
| 405 |
+
|
| 406 |
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
| 407 |
+
results = list(executor.map(lambda _index: refresh(), range(workers)))
|
| 408 |
+
|
| 409 |
+
self.assertEqual(provider.calls, 1)
|
| 410 |
+
self.assertTrue(all(result is results[0] for result in results))
|
| 411 |
+
self.assertIsNotNone(results[0])
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
if __name__ == "__main__":
|
| 415 |
+
unittest.main()
|
test/test_register_proxy_runtime.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from unittest.mock import patch
|
| 3 |
+
|
| 4 |
+
from services.proxy_service import ClearanceBundle
|
| 5 |
+
from services.register import openai_register
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class FakeResponse:
|
| 9 |
+
def __init__(self, status_code=200, text="", headers=None, url="https://auth.openai.com/test"):
|
| 10 |
+
self.status_code = status_code
|
| 11 |
+
self.text = text
|
| 12 |
+
self.headers = headers or {}
|
| 13 |
+
self.url = url
|
| 14 |
+
|
| 15 |
+
def json(self):
|
| 16 |
+
return {}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FakeCookieJar:
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self.items = []
|
| 22 |
+
|
| 23 |
+
def set(self, name, value, domain=None):
|
| 24 |
+
self.items.append({"name": name, "value": value, "domain": domain})
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class FakeSession:
|
| 28 |
+
def __init__(self, **kwargs):
|
| 29 |
+
self.kwargs = kwargs
|
| 30 |
+
self.headers = {}
|
| 31 |
+
self.cookies = FakeCookieJar()
|
| 32 |
+
self.closed = False
|
| 33 |
+
|
| 34 |
+
def close(self):
|
| 35 |
+
self.closed = True
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class FakeProxySettings:
|
| 39 |
+
def __init__(self, bundle=None):
|
| 40 |
+
self.bundle = bundle
|
| 41 |
+
self.refreshed = False
|
| 42 |
+
self.session_kwargs_calls = []
|
| 43 |
+
self.build_headers_calls = []
|
| 44 |
+
self.refresh_calls = []
|
| 45 |
+
|
| 46 |
+
def build_session_kwargs(self, **kwargs):
|
| 47 |
+
self.session_kwargs_calls.append(kwargs)
|
| 48 |
+
return dict(kwargs, proxy="http://runtime.example:8118")
|
| 49 |
+
|
| 50 |
+
def build_headers(self, headers=None, target_url="", proxy="", upstream=True, **kwargs):
|
| 51 |
+
self.build_headers_calls.append({"target_url": target_url, "proxy": proxy, "upstream": upstream})
|
| 52 |
+
merged = dict(headers or {})
|
| 53 |
+
if self.refreshed and self.bundle and self.bundle.cookies:
|
| 54 |
+
merged["Cookie"] = "; ".join(f"{key}={value}" for key, value in self.bundle.cookies.items())
|
| 55 |
+
return merged
|
| 56 |
+
|
| 57 |
+
def refresh_clearance(self, target_url="", proxy="", force=False, upstream=True, **kwargs):
|
| 58 |
+
self.refresh_calls.append({"target_url": target_url, "proxy": proxy, "force": force, "upstream": upstream})
|
| 59 |
+
self.refreshed = self.bundle is not None
|
| 60 |
+
return self.bundle
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class RegisterProxyRuntimeTests(unittest.TestCase):
|
| 64 |
+
def test_create_session_uses_proxy_settings_without_breaking_existing_proxy_argument(self):
|
| 65 |
+
fake_proxy = FakeProxySettings()
|
| 66 |
+
created = []
|
| 67 |
+
|
| 68 |
+
def fake_session_factory(**kwargs):
|
| 69 |
+
session = FakeSession(**kwargs)
|
| 70 |
+
created.append(session)
|
| 71 |
+
return session
|
| 72 |
+
|
| 73 |
+
with patch.object(openai_register, "proxy_settings", fake_proxy), patch.object(
|
| 74 |
+
openai_register.requests,
|
| 75 |
+
"Session",
|
| 76 |
+
side_effect=fake_session_factory,
|
| 77 |
+
):
|
| 78 |
+
session = openai_register.create_session("http://legacy-register.example:8080")
|
| 79 |
+
|
| 80 |
+
self.assertIs(session, created[0])
|
| 81 |
+
self.assertEqual(fake_proxy.session_kwargs_calls[0]["proxy"], "http://legacy-register.example:8080")
|
| 82 |
+
self.assertTrue(fake_proxy.session_kwargs_calls[0]["upstream"])
|
| 83 |
+
self.assertEqual(fake_proxy.session_kwargs_calls[0]["impersonate"], "chrome")
|
| 84 |
+
self.assertFalse(fake_proxy.session_kwargs_calls[0]["verify"])
|
| 85 |
+
self.assertEqual(session.kwargs["proxy"], "http://runtime.example:8118")
|
| 86 |
+
|
| 87 |
+
def test_cloudflare_without_clearance_keeps_clear_register_error(self):
|
| 88 |
+
fake_proxy = FakeProxySettings(bundle=None)
|
| 89 |
+
cf_response = FakeResponse(
|
| 90 |
+
status_code=403,
|
| 91 |
+
text="<html><title>Just a moment...</title></html>",
|
| 92 |
+
headers={"server": "cloudflare", "content-type": "text/html"},
|
| 93 |
+
url="https://auth.openai.com/api/accounts/authorize",
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
with patch.object(openai_register, "proxy_settings", fake_proxy), patch.object(
|
| 97 |
+
openai_register,
|
| 98 |
+
"create_session",
|
| 99 |
+
return_value=FakeSession(),
|
| 100 |
+
), patch.object(openai_register, "request_with_local_retry", return_value=(cf_response, "")):
|
| 101 |
+
registrar = openai_register.PlatformRegistrar(proxy="http://legacy-register.example:8080")
|
| 102 |
+
with self.assertRaisesRegex(RuntimeError, "Cloudflare") as ctx:
|
| 103 |
+
registrar._platform_authorize("user@example.com", 1)
|
| 104 |
+
|
| 105 |
+
self.assertEqual(len(fake_proxy.refresh_calls), 1)
|
| 106 |
+
self.assertIn("status=403", str(ctx.exception))
|
| 107 |
+
self.assertIn("Just a moment", str(ctx.exception))
|
| 108 |
+
|
| 109 |
+
def test_openai_html_behind_cloudflare_is_not_treated_as_challenge(self):
|
| 110 |
+
response = FakeResponse(
|
| 111 |
+
status_code=200,
|
| 112 |
+
text="""
|
| 113 |
+
<!DOCTYPE html><html lang=\"en-US\"><head>
|
| 114 |
+
<title>Create a password - OpenAI</title>
|
| 115 |
+
</head><body>OpenAI account page</body></html>
|
| 116 |
+
""",
|
| 117 |
+
headers={"server": "cloudflare", "content-type": "text/html; charset=utf-8"},
|
| 118 |
+
url="https://auth.openai.com/create-account/password",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
self.assertFalse(openai_register._is_cloudflare_challenge(response))
|
| 122 |
+
|
| 123 |
+
def test_cloudflare_challenge_refreshes_clearance_and_retries_once_with_matching_headers(self):
|
| 124 |
+
bundle = ClearanceBundle(
|
| 125 |
+
target_host="auth.openai.com",
|
| 126 |
+
proxy_url="http://runtime.example:8118",
|
| 127 |
+
cookies={"cf_clearance": "flare-token"},
|
| 128 |
+
user_agent="Flare UA",
|
| 129 |
+
)
|
| 130 |
+
fake_proxy = FakeProxySettings(bundle=bundle)
|
| 131 |
+
responses = [
|
| 132 |
+
FakeResponse(
|
| 133 |
+
status_code=403,
|
| 134 |
+
text="<html><title>Just a moment...</title></html>",
|
| 135 |
+
headers={"server": "cloudflare", "content-type": "text/html"},
|
| 136 |
+
url="https://auth.openai.com/api/accounts/authorize",
|
| 137 |
+
),
|
| 138 |
+
FakeResponse(status_code=200, text="{}", headers={"content-type": "application/json"}),
|
| 139 |
+
]
|
| 140 |
+
request_calls = []
|
| 141 |
+
|
| 142 |
+
def fake_request(session, method, url, retry_attempts=3, **kwargs):
|
| 143 |
+
request_calls.append({"method": method, "url": url, "headers": dict(kwargs.get("headers") or {})})
|
| 144 |
+
return responses.pop(0), ""
|
| 145 |
+
|
| 146 |
+
with patch.object(openai_register, "proxy_settings", fake_proxy), patch.object(
|
| 147 |
+
openai_register,
|
| 148 |
+
"create_session",
|
| 149 |
+
return_value=FakeSession(),
|
| 150 |
+
), patch.object(openai_register, "request_with_local_retry", side_effect=fake_request):
|
| 151 |
+
registrar = openai_register.PlatformRegistrar(proxy="http://legacy-register.example:8080")
|
| 152 |
+
registrar._platform_authorize("user@example.com", 1)
|
| 153 |
+
|
| 154 |
+
self.assertEqual(len(request_calls), 2)
|
| 155 |
+
self.assertEqual(len(fake_proxy.refresh_calls), 1)
|
| 156 |
+
retry_headers = {key.lower(): value for key, value in request_calls[1]["headers"].items()}
|
| 157 |
+
self.assertEqual(retry_headers["user-agent"], "Flare UA")
|
| 158 |
+
self.assertEqual(retry_headers["cookie"], "cf_clearance=flare-token")
|
| 159 |
+
self.assertEqual(fake_proxy.refresh_calls[0]["target_url"], openai_register.auth_base)
|
| 160 |
+
self.assertEqual(fake_proxy.refresh_calls[0]["proxy"], "http://legacy-register.example:8080")
|
| 161 |
+
self.assertTrue(fake_proxy.refresh_calls[0]["force"])
|
| 162 |
+
|
| 163 |
+
def test_refresh_failure_reports_cloudflare_detail_without_infinite_retry(self):
|
| 164 |
+
fake_proxy = FakeProxySettings(bundle=None)
|
| 165 |
+
cf_response = FakeResponse(
|
| 166 |
+
status_code=403,
|
| 167 |
+
text="<html><title>Just a moment...</title><body>challenge body</body></html>",
|
| 168 |
+
headers={"server": "cloudflare", "content-type": "text/html"},
|
| 169 |
+
url="https://auth.openai.com/api/accounts/authorize",
|
| 170 |
+
)
|
| 171 |
+
request_calls = []
|
| 172 |
+
|
| 173 |
+
def fake_request(session, method, url, retry_attempts=3, **kwargs):
|
| 174 |
+
request_calls.append({"method": method, "url": url})
|
| 175 |
+
return cf_response, ""
|
| 176 |
+
|
| 177 |
+
with patch.object(openai_register, "proxy_settings", fake_proxy), patch.object(
|
| 178 |
+
openai_register,
|
| 179 |
+
"create_session",
|
| 180 |
+
return_value=FakeSession(),
|
| 181 |
+
), patch.object(openai_register, "request_with_local_retry", side_effect=fake_request):
|
| 182 |
+
registrar = openai_register.PlatformRegistrar(proxy="")
|
| 183 |
+
with self.assertRaisesRegex(RuntimeError, "Cloudflare") as ctx:
|
| 184 |
+
registrar._platform_authorize("user@example.com", 1)
|
| 185 |
+
|
| 186 |
+
self.assertEqual(len(request_calls), 1)
|
| 187 |
+
self.assertEqual(len(fake_proxy.refresh_calls), 1)
|
| 188 |
+
message = str(ctx.exception)
|
| 189 |
+
self.assertIn("status=403", message)
|
| 190 |
+
self.assertIn("challenge body", message)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
if __name__ == "__main__":
|
| 194 |
+
unittest.main()
|
web/src/app/settings/components/proxy-runtime-card.tsx
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Cookie, LoaderCircle, PlugZap, ShieldCheck } from "lucide-react";
|
| 4 |
+
import { useState } from "react";
|
| 5 |
+
import { toast } from "sonner";
|
| 6 |
+
|
| 7 |
+
import { Button } from "@/components/ui/button";
|
| 8 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 9 |
+
import { Checkbox } from "@/components/ui/checkbox";
|
| 10 |
+
import { Input } from "@/components/ui/input";
|
| 11 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 12 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 13 |
+
import {
|
| 14 |
+
testProxy,
|
| 15 |
+
testProxyClearance,
|
| 16 |
+
type ClearanceTestResult,
|
| 17 |
+
type ProxyRuntimeClearanceMode,
|
| 18 |
+
type ProxyRuntimeEgressMode,
|
| 19 |
+
type ProxyTestResult,
|
| 20 |
+
} from "@/lib/api";
|
| 21 |
+
|
| 22 |
+
import { useSettingsStore } from "../store";
|
| 23 |
+
|
| 24 |
+
export function ProxyRuntimeCard() {
|
| 25 |
+
const [isTestingProxy, setIsTestingProxy] = useState(false);
|
| 26 |
+
const [isTestingClearance, setIsTestingClearance] = useState(false);
|
| 27 |
+
const [proxyResult, setProxyResult] = useState<ProxyTestResult | null>(null);
|
| 28 |
+
const [clearanceResult, setClearanceResult] = useState<ClearanceTestResult | null>(null);
|
| 29 |
+
const [targetUrl, setTargetUrl] = useState("https://chatgpt.com");
|
| 30 |
+
const config = useSettingsStore((state) => state.config);
|
| 31 |
+
const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
|
| 32 |
+
const saveConfig = useSettingsStore((state) => state.saveConfig);
|
| 33 |
+
const setProxyRuntimeField = useSettingsStore((state) => state.setProxyRuntimeField);
|
| 34 |
+
const setProxyRuntimeClearanceField = useSettingsStore((state) => state.setProxyRuntimeClearanceField);
|
| 35 |
+
const setProxyRuntimeStatusCodesText = useSettingsStore((state) => state.setProxyRuntimeStatusCodesText);
|
| 36 |
+
|
| 37 |
+
if (isLoadingConfig || !config?.proxy_runtime) {
|
| 38 |
+
return (
|
| 39 |
+
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
| 40 |
+
<CardContent className="flex items-center justify-center p-10">
|
| 41 |
+
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
| 42 |
+
</CardContent>
|
| 43 |
+
</Card>
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const runtime = config.proxy_runtime;
|
| 48 |
+
const clearance = runtime.clearance;
|
| 49 |
+
const runtimeEnabled = Boolean(runtime.enabled);
|
| 50 |
+
const clearanceMode = clearance.mode;
|
| 51 |
+
const hasStoredClearance = Boolean(clearance.has_cf_cookies || clearance.has_cf_clearance);
|
| 52 |
+
|
| 53 |
+
const handleTestRuntimeProxy = async () => {
|
| 54 |
+
setIsTestingProxy(true);
|
| 55 |
+
setProxyResult(null);
|
| 56 |
+
try {
|
| 57 |
+
const saved = await saveConfig();
|
| 58 |
+
if (!saved) {
|
| 59 |
+
return;
|
| 60 |
+
}
|
| 61 |
+
const data = await testProxy();
|
| 62 |
+
setProxyResult(data.result);
|
| 63 |
+
if (data.result.ok) {
|
| 64 |
+
toast.success(`稳定代理可用(${data.result.latency_ms} ms,HTTP ${data.result.status})`);
|
| 65 |
+
} else {
|
| 66 |
+
toast.error(`稳定代理不可用:${data.result.error ?? "未知错误"}`);
|
| 67 |
+
}
|
| 68 |
+
} catch (error) {
|
| 69 |
+
toast.error(error instanceof Error ? error.message : "测试稳定代理失败");
|
| 70 |
+
} finally {
|
| 71 |
+
setIsTestingProxy(false);
|
| 72 |
+
}
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
const handleTestClearance = async () => {
|
| 76 |
+
setIsTestingClearance(true);
|
| 77 |
+
setClearanceResult(null);
|
| 78 |
+
try {
|
| 79 |
+
const saved = await saveConfig();
|
| 80 |
+
if (!saved) {
|
| 81 |
+
return;
|
| 82 |
+
}
|
| 83 |
+
const data = await testProxyClearance(targetUrl.trim() || "https://chatgpt.com");
|
| 84 |
+
setClearanceResult(data.result);
|
| 85 |
+
if (data.result.ok) {
|
| 86 |
+
toast.success(`Clearance 获取成功(${data.result.latency_ms} ms)`);
|
| 87 |
+
} else {
|
| 88 |
+
toast.error(`Clearance 获取失败:${data.result.error ?? data.result.status}`);
|
| 89 |
+
}
|
| 90 |
+
} catch (error) {
|
| 91 |
+
toast.error(error instanceof Error ? error.message : "测试 Clearance 失败");
|
| 92 |
+
} finally {
|
| 93 |
+
setIsTestingClearance(false);
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
| 99 |
+
<CardContent className="space-y-5 p-6">
|
| 100 |
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
| 101 |
+
<div>
|
| 102 |
+
<div className="flex items-center gap-2 text-base font-semibold text-stone-900">
|
| 103 |
+
<PlugZap className="size-5 text-stone-500" />
|
| 104 |
+
稳定代理运行时
|
| 105 |
+
</div>
|
| 106 |
+
<p className="mt-1 text-xs leading-6 text-stone-500">
|
| 107 |
+
默认关闭。启用后仅接管上游 OpenAI/ChatGPT 请求,可配合 WARP / Privoxy / FlareSolverr 提升注册和生图链路稳定性。
|
| 108 |
+
</p>
|
| 109 |
+
</div>
|
| 110 |
+
<span className={`rounded-full px-3 py-1 text-xs ${runtimeEnabled ? "bg-emerald-50 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
| 111 |
+
{runtimeEnabled ? "已启用" : "未启用"}
|
| 112 |
+
</span>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-xs leading-6 text-stone-600">
|
| 116 |
+
代理优先级:账号代理 > 稳定代理运行时 > 显式代理 > 全局代理。Cookie / cf_clearance 不会在接口响应中明文返回。
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<div className="grid gap-4 md:grid-cols-2">
|
| 120 |
+
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700 md:col-span-2">
|
| 121 |
+
<Checkbox
|
| 122 |
+
checked={runtimeEnabled}
|
| 123 |
+
onCheckedChange={(checked) => setProxyRuntimeField("enabled", Boolean(checked))}
|
| 124 |
+
/>
|
| 125 |
+
启用稳定代理运行时
|
| 126 |
+
</label>
|
| 127 |
+
|
| 128 |
+
<div className="space-y-2">
|
| 129 |
+
<label className="text-sm text-stone-700">出站模式</label>
|
| 130 |
+
<Select
|
| 131 |
+
value={runtime.egress_mode}
|
| 132 |
+
onValueChange={(value) => setProxyRuntimeField("egress_mode", value as ProxyRuntimeEgressMode)}
|
| 133 |
+
disabled={!runtimeEnabled}
|
| 134 |
+
>
|
| 135 |
+
<SelectTrigger className="h-10 rounded-xl border-stone-200 bg-white shadow-none">
|
| 136 |
+
<SelectValue />
|
| 137 |
+
</SelectTrigger>
|
| 138 |
+
<SelectContent>
|
| 139 |
+
<SelectItem value="direct">直连</SelectItem>
|
| 140 |
+
<SelectItem value="single_proxy">单代理/WARP</SelectItem>
|
| 141 |
+
</SelectContent>
|
| 142 |
+
</Select>
|
| 143 |
+
<p className="text-xs text-stone-500">WARP compose 默认使用 single_proxy。</p>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div className="space-y-2">
|
| 147 |
+
<label className="text-sm text-stone-700">运行时代理 URL</label>
|
| 148 |
+
<Input
|
| 149 |
+
value={runtime.proxy_url}
|
| 150 |
+
onChange={(event) => setProxyRuntimeField("proxy_url", event.target.value)}
|
| 151 |
+
placeholder="http://privoxy:8118"
|
| 152 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 153 |
+
disabled={!runtimeEnabled || runtime.egress_mode !== "single_proxy"}
|
| 154 |
+
/>
|
| 155 |
+
<p className="text-xs text-stone-500">支持 http/https/socks,socks5 会转为 socks5h。</p>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<div className="space-y-2">
|
| 159 |
+
<label className="text-sm text-stone-700">资源代理 URL</label>
|
| 160 |
+
<Input
|
| 161 |
+
value={runtime.resource_proxy_url}
|
| 162 |
+
onChange={(event) => setProxyRuntimeField("resource_proxy_url", event.target.value)}
|
| 163 |
+
placeholder="留空则复用运行时代理"
|
| 164 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 165 |
+
disabled={!runtimeEnabled || runtime.egress_mode !== "single_proxy"}
|
| 166 |
+
/>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<div className="space-y-2">
|
| 170 |
+
<label className="text-sm text-stone-700">重置会话状态码</label>
|
| 171 |
+
<Input
|
| 172 |
+
value={runtime.reset_session_status_codes.join(",")}
|
| 173 |
+
onChange={(event) => setProxyRuntimeStatusCodesText(event.target.value)}
|
| 174 |
+
placeholder="403"
|
| 175 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 176 |
+
disabled={!runtimeEnabled}
|
| 177 |
+
/>
|
| 178 |
+
<p className="text-xs text-stone-500">默认 403,只对 Cloudflare/挑战类错误触发。</p>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-700">
|
| 182 |
+
<Checkbox
|
| 183 |
+
checked={Boolean(runtime.skip_ssl_verify)}
|
| 184 |
+
onCheckedChange={(checked) => setProxyRuntimeField("skip_ssl_verify", Boolean(checked))}
|
| 185 |
+
disabled={!runtimeEnabled}
|
| 186 |
+
/>
|
| 187 |
+
跳过 SSL 校验
|
| 188 |
+
</label>
|
| 189 |
+
|
| 190 |
+
<div className="flex items-end justify-end">
|
| 191 |
+
<Button
|
| 192 |
+
type="button"
|
| 193 |
+
variant="outline"
|
| 194 |
+
className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
| 195 |
+
onClick={() => void handleTestRuntimeProxy()}
|
| 196 |
+
disabled={isTestingProxy || !runtimeEnabled}
|
| 197 |
+
>
|
| 198 |
+
{isTestingProxy ? <LoaderCircle className="size-4 animate-spin" /> : <PlugZap className="size-4" />}
|
| 199 |
+
测试当前运行时代理
|
| 200 |
+
</Button>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{proxyResult ? (
|
| 204 |
+
<div className={`rounded-xl border px-3 py-2 text-xs leading-6 md:col-span-2 ${proxyResult.ok ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-rose-200 bg-rose-50 text-rose-800"}`}>
|
| 205 |
+
{proxyResult.ok
|
| 206 |
+
? `代理可用:HTTP ${proxyResult.status},用时 ${proxyResult.latency_ms} ms,来源 ${proxyResult.proxy_source ?? "unknown"}`
|
| 207 |
+
: `代理不可用:${proxyResult.error ?? "未知错误"}(用时 ${proxyResult.latency_ms} ms)`}
|
| 208 |
+
</div>
|
| 209 |
+
) : null}
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<div className="space-y-4 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
| 213 |
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
| 214 |
+
<div className="flex items-center gap-2 text-sm font-medium text-stone-800">
|
| 215 |
+
<Cookie className="size-4 text-stone-500" />
|
| 216 |
+
Cloudflare Clearance
|
| 217 |
+
</div>
|
| 218 |
+
<span className={`rounded-full px-3 py-1 text-xs ${clearance.enabled ? "bg-emerald-50 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
|
| 219 |
+
{clearance.enabled ? clearanceMode : "disabled"}
|
| 220 |
+
</span>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
<div className="grid gap-4 md:grid-cols-2">
|
| 224 |
+
<div className="space-y-2">
|
| 225 |
+
<label className="text-sm text-stone-700">Clearance 模式</label>
|
| 226 |
+
<Select
|
| 227 |
+
value={clearanceMode}
|
| 228 |
+
onValueChange={(value) => {
|
| 229 |
+
const mode = value as ProxyRuntimeClearanceMode;
|
| 230 |
+
setProxyRuntimeClearanceField("mode", mode);
|
| 231 |
+
setProxyRuntimeClearanceField("enabled", mode !== "none");
|
| 232 |
+
}}
|
| 233 |
+
disabled={!runtimeEnabled}
|
| 234 |
+
>
|
| 235 |
+
<SelectTrigger className="h-10 rounded-xl border-stone-200 bg-white shadow-none">
|
| 236 |
+
<SelectValue />
|
| 237 |
+
</SelectTrigger>
|
| 238 |
+
<SelectContent>
|
| 239 |
+
<SelectItem value="none">不启用</SelectItem>
|
| 240 |
+
<SelectItem value="manual">手动 Cookie</SelectItem>
|
| 241 |
+
<SelectItem value="flaresolverr">FlareSolverr</SelectItem>
|
| 242 |
+
</SelectContent>
|
| 243 |
+
</Select>
|
| 244 |
+
</div>
|
| 245 |
+
|
| 246 |
+
<div className="space-y-2">
|
| 247 |
+
<label className="text-sm text-stone-700">FlareSolverr URL</label>
|
| 248 |
+
<Input
|
| 249 |
+
value={clearance.flaresolverr_url}
|
| 250 |
+
onChange={(event) => setProxyRuntimeClearanceField("flaresolverr_url", event.target.value)}
|
| 251 |
+
placeholder="http://flaresolverr:8191"
|
| 252 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 253 |
+
disabled={!runtimeEnabled || clearanceMode !== "flaresolverr"}
|
| 254 |
+
/>
|
| 255 |
+
</div>
|
| 256 |
+
|
| 257 |
+
<div className="space-y-2 md:col-span-2">
|
| 258 |
+
<label className="text-sm text-stone-700">User-Agent</label>
|
| 259 |
+
<Input
|
| 260 |
+
value={clearance.user_agent}
|
| 261 |
+
onChange={(event) => setProxyRuntimeClearanceField("user_agent", event.target.value)}
|
| 262 |
+
className="h-10 rounded-xl border-stone-200 bg-white font-mono text-xs"
|
| 263 |
+
disabled={!runtimeEnabled || clearanceMode === "none"}
|
| 264 |
+
/>
|
| 265 |
+
</div>
|
| 266 |
+
|
| 267 |
+
<div className="space-y-2">
|
| 268 |
+
<label className="text-sm text-stone-700">超时秒数</label>
|
| 269 |
+
<Input
|
| 270 |
+
value={String(clearance.timeout_sec)}
|
| 271 |
+
onChange={(event) => setProxyRuntimeClearanceField("timeout_sec", event.target.value)}
|
| 272 |
+
placeholder="60"
|
| 273 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 274 |
+
disabled={!runtimeEnabled || clearanceMode === "none"}
|
| 275 |
+
/>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<div className="space-y-2">
|
| 279 |
+
<label className="text-sm text-stone-700">刷新间隔秒数</label>
|
| 280 |
+
<Input
|
| 281 |
+
value={String(clearance.refresh_interval)}
|
| 282 |
+
onChange={(event) => setProxyRuntimeClearanceField("refresh_interval", event.target.value)}
|
| 283 |
+
placeholder="3600"
|
| 284 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 285 |
+
disabled={!runtimeEnabled || clearanceMode === "none"}
|
| 286 |
+
/>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
<div className="space-y-2 md:col-span-2">
|
| 290 |
+
<label className="text-sm text-stone-700">手动 Cookie</label>
|
| 291 |
+
<Textarea
|
| 292 |
+
value={clearance.cf_cookies}
|
| 293 |
+
onChange={(event) => setProxyRuntimeClearanceField("cf_cookies", event.target.value)}
|
| 294 |
+
placeholder="可选:foo=bar; cf_clearance=..."
|
| 295 |
+
className="min-h-24 rounded-xl border-stone-200 bg-white font-mono text-xs shadow-none"
|
| 296 |
+
disabled={!runtimeEnabled || clearanceMode !== "manual"}
|
| 297 |
+
/>
|
| 298 |
+
<p className="text-xs text-stone-500">
|
| 299 |
+
{hasStoredClearance ? "服务端已保存过 Cookie/clearance;留空保存不会清空已有值。" : "留空表示不使用手动 Cookie。"}
|
| 300 |
+
</p>
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
<div className="space-y-2 md:col-span-2">
|
| 304 |
+
<label className="text-sm text-stone-700">单独 cf_clearance</label>
|
| 305 |
+
<Input
|
| 306 |
+
value={clearance.cf_clearance}
|
| 307 |
+
onChange={(event) => setProxyRuntimeClearanceField("cf_clearance", event.target.value)}
|
| 308 |
+
placeholder="可选:只填写 cf_clearance 值"
|
| 309 |
+
className="h-10 rounded-xl border-stone-200 bg-white font-mono text-xs"
|
| 310 |
+
disabled={!runtimeEnabled || clearanceMode !== "manual"}
|
| 311 |
+
/>
|
| 312 |
+
</div>
|
| 313 |
+
|
| 314 |
+
<label className="flex items-center gap-3 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-700">
|
| 315 |
+
<Checkbox
|
| 316 |
+
checked={Boolean(clearance.warm_up_on_start)}
|
| 317 |
+
onCheckedChange={(checked) => setProxyRuntimeClearanceField("warm_up_on_start", Boolean(checked))}
|
| 318 |
+
disabled={!runtimeEnabled || clearanceMode === "none"}
|
| 319 |
+
/>
|
| 320 |
+
启动时预热 Clearance
|
| 321 |
+
</label>
|
| 322 |
+
|
| 323 |
+
<div className="space-y-2">
|
| 324 |
+
<label className="text-sm text-stone-700">测试目标 URL</label>
|
| 325 |
+
<Input
|
| 326 |
+
value={targetUrl}
|
| 327 |
+
onChange={(event) => setTargetUrl(event.target.value)}
|
| 328 |
+
placeholder="https://chatgpt.com"
|
| 329 |
+
className="h-10 rounded-xl border-stone-200 bg-white"
|
| 330 |
+
disabled={!runtimeEnabled || clearanceMode === "none"}
|
| 331 |
+
/>
|
| 332 |
+
</div>
|
| 333 |
+
|
| 334 |
+
<div className="flex justify-end md:col-span-2">
|
| 335 |
+
<Button
|
| 336 |
+
type="button"
|
| 337 |
+
variant="outline"
|
| 338 |
+
className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
|
| 339 |
+
onClick={() => void handleTestClearance()}
|
| 340 |
+
disabled={isTestingClearance || !runtimeEnabled || clearanceMode === "none"}
|
| 341 |
+
>
|
| 342 |
+
{isTestingClearance ? <LoaderCircle className="size-4 animate-spin" /> : <ShieldCheck className="size-4" />}
|
| 343 |
+
测试 Clearance
|
| 344 |
+
</Button>
|
| 345 |
+
</div>
|
| 346 |
+
|
| 347 |
+
{clearanceResult ? (
|
| 348 |
+
<div className={`rounded-xl border px-3 py-2 text-xs leading-6 md:col-span-2 ${clearanceResult.ok ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-rose-200 bg-rose-50 text-rose-800"}`}>
|
| 349 |
+
{clearanceResult.ok
|
| 350 |
+
? `Clearance 可用:${clearanceResult.has_cookies ? "已获取 Cookie" : "无 Cookie"},用时 ${clearanceResult.latency_ms} ms`
|
| 351 |
+
: `Clearance 不可用:${clearanceResult.error ?? clearanceResult.status}(用时 ${clearanceResult.latency_ms} ms)`}
|
| 352 |
+
</div>
|
| 353 |
+
) : null}
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
</CardContent>
|
| 357 |
+
</Card>
|
| 358 |
+
);
|
| 359 |
+
}
|
web/src/app/settings/page.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { ConfigCard } from "./components/config-card";
|
|
| 10 |
import { CPAPoolDialog } from "./components/cpa-pool-dialog";
|
| 11 |
import { CPAPoolsCard } from "./components/cpa-pools-card";
|
| 12 |
import { ImportBrowserDialog } from "./components/import-browser-dialog";
|
|
|
|
| 13 |
import { SettingsHeader } from "./components/settings-header";
|
| 14 |
import { Sub2APIConnections } from "./components/sub2api-connections";
|
| 15 |
import { UserKeysCard } from "./components/user-keys-card";
|
|
@@ -66,6 +67,7 @@ function SettingsPageContent() {
|
|
| 66 |
<SettingsHeader />
|
| 67 |
<section className="space-y-6">
|
| 68 |
<ConfigCard />
|
|
|
|
| 69 |
<BackupSettingsCard />
|
| 70 |
<UserKeysCard />
|
| 71 |
<CPAPoolsCard />
|
|
|
|
| 10 |
import { CPAPoolDialog } from "./components/cpa-pool-dialog";
|
| 11 |
import { CPAPoolsCard } from "./components/cpa-pools-card";
|
| 12 |
import { ImportBrowserDialog } from "./components/import-browser-dialog";
|
| 13 |
+
import { ProxyRuntimeCard } from "./components/proxy-runtime-card";
|
| 14 |
import { SettingsHeader } from "./components/settings-header";
|
| 15 |
import { Sub2APIConnections } from "./components/sub2api-connections";
|
| 16 |
import { UserKeysCard } from "./components/user-keys-card";
|
|
|
|
| 67 |
<SettingsHeader />
|
| 68 |
<section className="space-y-6">
|
| 69 |
<ConfigCard />
|
| 70 |
+
<ProxyRuntimeCard />
|
| 71 |
<BackupSettingsCard />
|
| 72 |
<UserKeysCard />
|
| 73 |
<CPAPoolsCard />
|
web/src/app/settings/store.ts
CHANGED
|
@@ -31,6 +31,9 @@ import {
|
|
| 31 |
type CPARemoteFile,
|
| 32 |
type ImageStorageMode,
|
| 33 |
type ImageStorageSettings,
|
|
|
|
|
|
|
|
|
|
| 34 |
type RegisterConfig,
|
| 35 |
type SettingsConfig,
|
| 36 |
} from "@/lib/api";
|
|
@@ -39,6 +42,71 @@ export const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const;
|
|
| 39 |
|
| 40 |
export type PageSizeOption = (typeof PAGE_SIZE_OPTIONS)[number];
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
function normalizeConfig(config: SettingsConfig): SettingsConfig {
|
| 43 |
const imageStorage = typeof config.image_storage === "object" && config.image_storage
|
| 44 |
? config.image_storage as ImageStorageSettings
|
|
@@ -116,6 +184,7 @@ function normalizeConfig(config: SettingsConfig): SettingsConfig {
|
|
| 116 |
webdav_root_path: String(imageStorage.webdav_root_path || "chatgpt2api/images"),
|
| 117 |
public_base_url: String(imageStorage.public_base_url || ""),
|
| 118 |
},
|
|
|
|
| 119 |
backup: {
|
| 120 |
...backup,
|
| 121 |
enabled: Boolean(backup.enabled),
|
|
@@ -224,6 +293,9 @@ type SettingsStore = {
|
|
| 224 |
setSensitiveWordsText: (value: string) => void;
|
| 225 |
setAIReviewField: (key: "enabled" | "base_url" | "api_key" | "model" | "prompt", value: string | boolean) => void;
|
| 226 |
setImageStorageField: (key: keyof ImageStorageSettings, value: string | boolean) => void;
|
|
|
|
|
|
|
|
|
|
| 227 |
testImageStorage: () => Promise<void>;
|
| 228 |
syncImagesToWebDAV: () => Promise<void>;
|
| 229 |
setBackupField: (key: keyof BackupSettings, value: string | boolean) => void;
|
|
@@ -379,6 +451,26 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|
| 379 |
webdav_root_path: String(config.image_storage?.webdav_root_path || "chatgpt2api/images").trim(),
|
| 380 |
public_base_url: String(config.image_storage?.public_base_url || "").trim(),
|
| 381 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
backup: {
|
| 383 |
...(config.backup as BackupSettings),
|
| 384 |
account_id: String(config.backup?.account_id || "").trim(),
|
|
@@ -532,6 +624,69 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|
| 532 |
});
|
| 533 |
},
|
| 534 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
testImageStorage: async () => {
|
| 536 |
set({ isTestingImageStorage: true });
|
| 537 |
try {
|
|
|
|
| 31 |
type CPARemoteFile,
|
| 32 |
type ImageStorageMode,
|
| 33 |
type ImageStorageSettings,
|
| 34 |
+
type ProxyRuntimeClearanceMode,
|
| 35 |
+
type ProxyRuntimeEgressMode,
|
| 36 |
+
type ProxyRuntimeSettings,
|
| 37 |
type RegisterConfig,
|
| 38 |
type SettingsConfig,
|
| 39 |
} from "@/lib/api";
|
|
|
|
| 42 |
|
| 43 |
export type PageSizeOption = (typeof PAGE_SIZE_OPTIONS)[number];
|
| 44 |
|
| 45 |
+
const DEFAULT_PROXY_RUNTIME: ProxyRuntimeSettings = {
|
| 46 |
+
enabled: false,
|
| 47 |
+
egress_mode: "direct",
|
| 48 |
+
proxy_url: "",
|
| 49 |
+
resource_proxy_url: "",
|
| 50 |
+
skip_ssl_verify: false,
|
| 51 |
+
reset_session_status_codes: [403],
|
| 52 |
+
clearance: {
|
| 53 |
+
enabled: false,
|
| 54 |
+
mode: "none",
|
| 55 |
+
cf_cookies: "",
|
| 56 |
+
cf_clearance: "",
|
| 57 |
+
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
| 58 |
+
browser: "chrome",
|
| 59 |
+
flaresolverr_url: "",
|
| 60 |
+
timeout_sec: 60,
|
| 61 |
+
refresh_interval: 3600,
|
| 62 |
+
warm_up_on_start: false,
|
| 63 |
+
has_cf_cookies: false,
|
| 64 |
+
has_cf_clearance: false,
|
| 65 |
+
},
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
function normalizeProxyRuntime(value: unknown): ProxyRuntimeSettings {
|
| 69 |
+
const source = typeof value === "object" && value !== null ? value as Partial<ProxyRuntimeSettings> : {};
|
| 70 |
+
const clearanceSource = typeof source.clearance === "object" && source.clearance !== null
|
| 71 |
+
? source.clearance as Partial<ProxyRuntimeSettings["clearance"]>
|
| 72 |
+
: {};
|
| 73 |
+
const egressMode = source.egress_mode === "single_proxy" ? "single_proxy" : "direct";
|
| 74 |
+
const clearanceMode: ProxyRuntimeClearanceMode = clearanceSource.mode === "manual" || clearanceSource.mode === "flaresolverr"
|
| 75 |
+
? clearanceSource.mode
|
| 76 |
+
: "none";
|
| 77 |
+
const statusCodes = Array.isArray(source.reset_session_status_codes)
|
| 78 |
+
? source.reset_session_status_codes
|
| 79 |
+
.map((item) => Number(item))
|
| 80 |
+
.filter((item) => Number.isInteger(item) && item >= 100 && item <= 599)
|
| 81 |
+
: [];
|
| 82 |
+
return {
|
| 83 |
+
...DEFAULT_PROXY_RUNTIME,
|
| 84 |
+
...source,
|
| 85 |
+
enabled: Boolean(source.enabled),
|
| 86 |
+
egress_mode: egressMode as ProxyRuntimeEgressMode,
|
| 87 |
+
proxy_url: String(source.proxy_url || ""),
|
| 88 |
+
resource_proxy_url: String(source.resource_proxy_url || ""),
|
| 89 |
+
skip_ssl_verify: Boolean(source.skip_ssl_verify),
|
| 90 |
+
reset_session_status_codes: statusCodes.length > 0 ? statusCodes : [403],
|
| 91 |
+
clearance: {
|
| 92 |
+
...DEFAULT_PROXY_RUNTIME.clearance,
|
| 93 |
+
...clearanceSource,
|
| 94 |
+
enabled: Boolean(clearanceSource.enabled),
|
| 95 |
+
mode: clearanceMode,
|
| 96 |
+
cf_cookies: String(clearanceSource.cf_cookies || ""),
|
| 97 |
+
cf_clearance: String(clearanceSource.cf_clearance || ""),
|
| 98 |
+
user_agent: String(clearanceSource.user_agent || DEFAULT_PROXY_RUNTIME.clearance.user_agent),
|
| 99 |
+
browser: String(clearanceSource.browser || "chrome"),
|
| 100 |
+
flaresolverr_url: String(clearanceSource.flaresolverr_url || ""),
|
| 101 |
+
timeout_sec: Number(clearanceSource.timeout_sec || 60),
|
| 102 |
+
refresh_interval: Number(clearanceSource.refresh_interval || 3600),
|
| 103 |
+
warm_up_on_start: Boolean(clearanceSource.warm_up_on_start),
|
| 104 |
+
has_cf_cookies: Boolean(clearanceSource.has_cf_cookies),
|
| 105 |
+
has_cf_clearance: Boolean(clearanceSource.has_cf_clearance),
|
| 106 |
+
},
|
| 107 |
+
};
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
function normalizeConfig(config: SettingsConfig): SettingsConfig {
|
| 111 |
const imageStorage = typeof config.image_storage === "object" && config.image_storage
|
| 112 |
? config.image_storage as ImageStorageSettings
|
|
|
|
| 184 |
webdav_root_path: String(imageStorage.webdav_root_path || "chatgpt2api/images"),
|
| 185 |
public_base_url: String(imageStorage.public_base_url || ""),
|
| 186 |
},
|
| 187 |
+
proxy_runtime: normalizeProxyRuntime(config.proxy_runtime),
|
| 188 |
backup: {
|
| 189 |
...backup,
|
| 190 |
enabled: Boolean(backup.enabled),
|
|
|
|
| 293 |
setSensitiveWordsText: (value: string) => void;
|
| 294 |
setAIReviewField: (key: "enabled" | "base_url" | "api_key" | "model" | "prompt", value: string | boolean) => void;
|
| 295 |
setImageStorageField: (key: keyof ImageStorageSettings, value: string | boolean) => void;
|
| 296 |
+
setProxyRuntimeField: <K extends keyof ProxyRuntimeSettings>(key: K, value: ProxyRuntimeSettings[K]) => void;
|
| 297 |
+
setProxyRuntimeClearanceField: <K extends keyof ProxyRuntimeSettings["clearance"]>(key: K, value: ProxyRuntimeSettings["clearance"][K]) => void;
|
| 298 |
+
setProxyRuntimeStatusCodesText: (value: string) => void;
|
| 299 |
testImageStorage: () => Promise<void>;
|
| 300 |
syncImagesToWebDAV: () => Promise<void>;
|
| 301 |
setBackupField: (key: keyof BackupSettings, value: string | boolean) => void;
|
|
|
|
| 451 |
webdav_root_path: String(config.image_storage?.webdav_root_path || "chatgpt2api/images").trim(),
|
| 452 |
public_base_url: String(config.image_storage?.public_base_url || "").trim(),
|
| 453 |
},
|
| 454 |
+
proxy_runtime: {
|
| 455 |
+
...normalizeProxyRuntime(config.proxy_runtime),
|
| 456 |
+
proxy_url: String(config.proxy_runtime?.proxy_url || "").trim(),
|
| 457 |
+
resource_proxy_url: String(config.proxy_runtime?.resource_proxy_url || "").trim(),
|
| 458 |
+
reset_session_status_codes: normalizeProxyRuntime({
|
| 459 |
+
reset_session_status_codes: (config.proxy_runtime?.reset_session_status_codes || [403])
|
| 460 |
+
.map((item) => Number(item))
|
| 461 |
+
.filter((item) => Number.isInteger(item) && item >= 100 && item <= 599),
|
| 462 |
+
}).reset_session_status_codes,
|
| 463 |
+
clearance: {
|
| 464 |
+
...normalizeProxyRuntime(config.proxy_runtime).clearance,
|
| 465 |
+
cf_cookies: String(config.proxy_runtime?.clearance?.cf_cookies || "").trim(),
|
| 466 |
+
cf_clearance: String(config.proxy_runtime?.clearance?.cf_clearance || "").trim(),
|
| 467 |
+
user_agent: String(config.proxy_runtime?.clearance?.user_agent || DEFAULT_PROXY_RUNTIME.clearance.user_agent).trim(),
|
| 468 |
+
browser: String(config.proxy_runtime?.clearance?.browser || "chrome").trim(),
|
| 469 |
+
flaresolverr_url: String(config.proxy_runtime?.clearance?.flaresolverr_url || "").trim(),
|
| 470 |
+
timeout_sec: Math.max(1, Number(config.proxy_runtime?.clearance?.timeout_sec) || 60),
|
| 471 |
+
refresh_interval: Math.max(60, Number(config.proxy_runtime?.clearance?.refresh_interval) || 3600),
|
| 472 |
+
},
|
| 473 |
+
},
|
| 474 |
backup: {
|
| 475 |
...(config.backup as BackupSettings),
|
| 476 |
account_id: String(config.backup?.account_id || "").trim(),
|
|
|
|
| 624 |
});
|
| 625 |
},
|
| 626 |
|
| 627 |
+
setProxyRuntimeField: (key, value) => {
|
| 628 |
+
set((state) => {
|
| 629 |
+
if (!state.config) {
|
| 630 |
+
return {};
|
| 631 |
+
}
|
| 632 |
+
const runtime = normalizeProxyRuntime(state.config.proxy_runtime);
|
| 633 |
+
const nextRuntime = normalizeProxyRuntime({
|
| 634 |
+
...runtime,
|
| 635 |
+
[key]: value,
|
| 636 |
+
});
|
| 637 |
+
return {
|
| 638 |
+
config: {
|
| 639 |
+
...state.config,
|
| 640 |
+
proxy_runtime: nextRuntime,
|
| 641 |
+
},
|
| 642 |
+
};
|
| 643 |
+
});
|
| 644 |
+
},
|
| 645 |
+
|
| 646 |
+
setProxyRuntimeClearanceField: (key, value) => {
|
| 647 |
+
set((state) => {
|
| 648 |
+
if (!state.config) {
|
| 649 |
+
return {};
|
| 650 |
+
}
|
| 651 |
+
const runtime = normalizeProxyRuntime(state.config.proxy_runtime);
|
| 652 |
+
const nextRuntime = normalizeProxyRuntime({
|
| 653 |
+
...runtime,
|
| 654 |
+
clearance: {
|
| 655 |
+
...runtime.clearance,
|
| 656 |
+
[key]: value,
|
| 657 |
+
},
|
| 658 |
+
});
|
| 659 |
+
return {
|
| 660 |
+
config: {
|
| 661 |
+
...state.config,
|
| 662 |
+
proxy_runtime: nextRuntime,
|
| 663 |
+
},
|
| 664 |
+
};
|
| 665 |
+
});
|
| 666 |
+
},
|
| 667 |
+
|
| 668 |
+
setProxyRuntimeStatusCodesText: (value) => {
|
| 669 |
+
const codes = value
|
| 670 |
+
.split(/[,\s]+/)
|
| 671 |
+
.map((item) => Number(item.trim()))
|
| 672 |
+
.filter((item) => Number.isInteger(item) && item >= 100 && item <= 599);
|
| 673 |
+
set((state) => {
|
| 674 |
+
if (!state.config) {
|
| 675 |
+
return {};
|
| 676 |
+
}
|
| 677 |
+
const runtime = normalizeProxyRuntime(state.config.proxy_runtime);
|
| 678 |
+
return {
|
| 679 |
+
config: {
|
| 680 |
+
...state.config,
|
| 681 |
+
proxy_runtime: normalizeProxyRuntime({
|
| 682 |
+
...runtime,
|
| 683 |
+
reset_session_status_codes: codes.length > 0 ? codes : [403],
|
| 684 |
+
}),
|
| 685 |
+
},
|
| 686 |
+
};
|
| 687 |
+
});
|
| 688 |
+
},
|
| 689 |
+
|
| 690 |
testImageStorage: async () => {
|
| 691 |
set({ isTestingImageStorage: true });
|
| 692 |
try {
|
web/src/lib/api.ts
CHANGED
|
@@ -101,6 +101,50 @@ type AccountUpdateResponse = {
|
|
| 101 |
items: Account[];
|
| 102 |
};
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
export type SettingsConfig = {
|
| 105 |
proxy: string;
|
| 106 |
base_url?: string;
|
|
@@ -127,6 +171,7 @@ export type SettingsConfig = {
|
|
| 127 |
auto_relogin_after_refresh?: boolean;
|
| 128 |
log_levels?: string[];
|
| 129 |
image_storage?: ImageStorageSettings;
|
|
|
|
| 130 |
backup?: BackupSettings;
|
| 131 |
backup_state?: BackupState;
|
| 132 |
[key: string]: unknown;
|
|
@@ -892,6 +937,18 @@ export type ProxyTestResult = {
|
|
| 892 |
status: number;
|
| 893 |
latency_ms: number;
|
| 894 |
error: string | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 895 |
};
|
| 896 |
|
| 897 |
export async function fetchProxy() {
|
|
@@ -911,3 +968,21 @@ export async function testProxy(url?: string) {
|
|
| 911 |
body: { url: url ?? "" },
|
| 912 |
});
|
| 913 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
items: Account[];
|
| 102 |
};
|
| 103 |
|
| 104 |
+
export type ProxyRuntimeEgressMode = "direct" | "single_proxy";
|
| 105 |
+
export type ProxyRuntimeClearanceMode = "none" | "manual" | "flaresolverr";
|
| 106 |
+
|
| 107 |
+
export type ProxyRuntimeClearanceSettings = {
|
| 108 |
+
enabled: boolean;
|
| 109 |
+
mode: ProxyRuntimeClearanceMode;
|
| 110 |
+
cf_cookies: string;
|
| 111 |
+
cf_clearance: string;
|
| 112 |
+
user_agent: string;
|
| 113 |
+
browser: string;
|
| 114 |
+
flaresolverr_url: string;
|
| 115 |
+
timeout_sec: number | string;
|
| 116 |
+
refresh_interval: number | string;
|
| 117 |
+
warm_up_on_start: boolean;
|
| 118 |
+
has_cf_cookies?: boolean;
|
| 119 |
+
has_cf_clearance?: boolean;
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
export type ProxyRuntimeSettings = {
|
| 123 |
+
enabled: boolean;
|
| 124 |
+
egress_mode: ProxyRuntimeEgressMode;
|
| 125 |
+
proxy_url: string;
|
| 126 |
+
resource_proxy_url: string;
|
| 127 |
+
skip_ssl_verify: boolean;
|
| 128 |
+
reset_session_status_codes: number[];
|
| 129 |
+
clearance: ProxyRuntimeClearanceSettings;
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
export type ProxyRuntimeStatus = {
|
| 133 |
+
enabled: boolean;
|
| 134 |
+
egress_mode: ProxyRuntimeEgressMode | string;
|
| 135 |
+
proxy_source: string;
|
| 136 |
+
has_proxy: boolean;
|
| 137 |
+
clearance_enabled: boolean;
|
| 138 |
+
clearance_mode: ProxyRuntimeClearanceMode | string;
|
| 139 |
+
has_clearance_bundle: boolean;
|
| 140 |
+
cached_clearance_hosts: string[];
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
export type ProxyRuntimeResponse = {
|
| 144 |
+
runtime: ProxyRuntimeSettings;
|
| 145 |
+
status: ProxyRuntimeStatus;
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
export type SettingsConfig = {
|
| 149 |
proxy: string;
|
| 150 |
base_url?: string;
|
|
|
|
| 171 |
auto_relogin_after_refresh?: boolean;
|
| 172 |
log_levels?: string[];
|
| 173 |
image_storage?: ImageStorageSettings;
|
| 174 |
+
proxy_runtime?: ProxyRuntimeSettings;
|
| 175 |
backup?: BackupSettings;
|
| 176 |
backup_state?: BackupState;
|
| 177 |
[key: string]: unknown;
|
|
|
|
| 937 |
status: number;
|
| 938 |
latency_ms: number;
|
| 939 |
error: string | null;
|
| 940 |
+
proxy_source?: string;
|
| 941 |
+
has_proxy?: boolean;
|
| 942 |
+
};
|
| 943 |
+
|
| 944 |
+
export type ClearanceTestResult = {
|
| 945 |
+
ok: boolean;
|
| 946 |
+
status: string;
|
| 947 |
+
latency_ms: number;
|
| 948 |
+
has_cookies: boolean;
|
| 949 |
+
user_agent: string;
|
| 950 |
+
error: string | null;
|
| 951 |
+
runtime: ProxyRuntimeStatus;
|
| 952 |
};
|
| 953 |
|
| 954 |
export async function fetchProxy() {
|
|
|
|
| 968 |
body: { url: url ?? "" },
|
| 969 |
});
|
| 970 |
}
|
| 971 |
+
|
| 972 |
+
export async function fetchProxyRuntime() {
|
| 973 |
+
return httpRequest<ProxyRuntimeResponse>("/api/proxy/runtime");
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
export async function updateProxyRuntime(runtime: ProxyRuntimeSettings) {
|
| 977 |
+
return httpRequest<ProxyRuntimeResponse>("/api/proxy/runtime", {
|
| 978 |
+
method: "POST",
|
| 979 |
+
body: runtime,
|
| 980 |
+
});
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
export async function testProxyClearance(targetUrl?: string) {
|
| 984 |
+
return httpRequest<{ result: ClearanceTestResult }>("/api/proxy/clearance/test", {
|
| 985 |
+
method: "POST",
|
| 986 |
+
body: { target_url: targetUrl ?? "https://chatgpt.com" },
|
| 987 |
+
});
|
| 988 |
+
}
|