HouYunFei commited on
Commit
9b5b14c
·
2 Parent(s): 2a362ae370f715

Merge branch 'fork/TizenryA/feat/warp-flaresolverr-runtime'

Browse files
.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
+ 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
- candidate = (body.url or "").strip() or config.get_proxy_settings()
135
- if not candidate:
136
- raise HTTPException(status_code=400, detail={"error": "proxy url is required"})
137
- return {"result": await run_in_threadpool(test_proxy, candidate)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 helpers for upstream ChatGPT and CPA requests."""
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 build_session_kwargs(self, account: dict | None = None, proxy: str = "", **session_kwargs) -> dict[str, object]:
15
- account_proxy = str((account or {}).get("proxy") or "").strip()
16
- proxy = str(proxy or account_proxy or config.get_proxy_settings()).strip()
17
- if proxy:
18
- session_kwargs["proxy"] = proxy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 test_proxy(url: str, *, timeout: float = 15.0) -> dict:
32
- candidate = _clean(url)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  if not candidate:
34
- return {"ok": False, "status": 0, "latency_ms": 0, "error": "proxy url is required"}
 
 
 
 
 
 
35
  if not _is_valid_proxy_url(candidate):
36
- return {"ok": False, "status": 0, "latency_ms": 0, "error": "invalid proxy url"}
 
 
 
 
 
 
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
- return (
200
- "cloudflare" in server
201
- or "challenges.cloudflare.com" in text
 
 
 
 
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 = {"impersonate": "chrome", "verify": False}
250
- if proxy:
251
- kwargs["proxy"] = proxy
 
 
 
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.session = create_session(proxy)
 
 
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
- resp, error = request_with_local_retry(self.session, "get", f"{auth_base}/api/accounts/authorize?{urlencode(params)}", headers=self._navigate_headers(f"{platform_base}/"), allow_redirects=True, verify=False)
 
 
 
 
 
 
 
 
 
 
 
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
- resp, error = request_with_local_retry(self.session, "post", f"{auth_base}/api/accounts/user/register", json={"username": email, "password": password}, headers=headers, verify=False)
 
 
 
 
 
 
 
 
 
 
 
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
- resp, error = request_with_local_retry(self.session, "get", f"{auth_base}/api/accounts/email-otp/send", headers=self._navigate_headers(f"{auth_base}/create-account/password"), allow_redirects=True, verify=False)
 
 
 
 
 
 
 
 
 
 
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
- resp, error = request_with_local_retry(self.session, "post", f"{auth_base}/api/accounts/create_account", json={"name": name, "birthdate": birthdate}, headers=headers, verify=False)
 
 
 
 
 
 
 
 
 
 
 
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
+ 代理优先级:账号代理 &gt; 稳定代理运行时 &gt; 显式代理 &gt; 全局代理。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
+ }