Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
4ebb914
1
Parent(s): 0e1248b
feat: add proxy pool for per-account proxy routing
Browse filesAdd proxy pool system allowing different accounts to route through
different upstream proxies for IP diversity and risk isolation.
Backend:
- ProxyPool manager with CRUD, assignment, round-robin, health checks
- TlsTransport interface accepts per-request proxyUrl parameter
- Both curl-cli and libcurl-ffi transports support per-request proxy
- CodexApi forwards proxy through the request chain
- REST API at /api/proxies for proxy + assignment management
- Periodic health checks (default 5min) via api.ipify.org
Frontend:
- ProxyPool management section in dashboard
- Per-account proxy selector dropdown in AccountCard
- Four assignment modes: Global/Direct/Auto(Round-Robin)/Specific
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CHANGELOG.md +10 -0
- shared/hooks/use-proxies.ts +168 -0
- shared/i18n/translations.ts +46 -0
- shared/types.ts +23 -0
- src/index.ts +14 -5
- src/models/model-fetcher.ts +10 -3
- src/proxy/codex-api.ts +7 -4
- src/proxy/proxy-pool.ts +447 -0
- src/routes/accounts.ts +22 -5
- src/routes/chat.ts +3 -0
- src/routes/gemini.ts +3 -0
- src/routes/messages.ts +3 -0
- src/routes/proxies.ts +171 -0
- src/routes/shared/proxy-handler.ts +6 -2
- src/tls/curl-cli-transport.ts +16 -3
- src/tls/libcurl-ffi-transport.ts +14 -6
- src/tls/transport.ts +15 -3
- web/src/App.tsx +11 -0
- web/src/components/AccountCard.tsx +28 -2
- web/src/components/AccountList.tsx +5 -3
- web/src/components/ProxyPool.tsx +267 -0
CHANGELOG.md
CHANGED
|
@@ -8,6 +8,16 @@
|
|
| 8 |
|
| 9 |
### Added
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
- Dashboard GitHub Star 徽章:Header 新增醒目的 ⭐ Star 按钮(amber 药丸样式),点击跳转 GitHub 仓库页面,方便用户收藏和获取更新
|
| 12 |
- Dashboard 检查更新功能:Footer 显示 Proxy 版本+commit 和 Codex Desktop 指纹版本,提供"检查更新"按钮同时检查两种更新
|
| 13 |
- Proxy 自更新(CLI 模式):通过 `git fetch` 检查新提交,自动执行 `git pull + npm install + npm run build`,完成后提示重启
|
|
|
|
| 8 |
|
| 9 |
### Added
|
| 10 |
|
| 11 |
+
- 代理池功能:支持为不同账号配置不同的上游代理,实现 IP 多样化和风险隔离
|
| 12 |
+
- 代理 CRUD:添加、删除、启用、禁用代理(HTTP/HTTPS/SOCKS5)
|
| 13 |
+
- 四种分配模式:Global Default(全局代理)、Direct(直连)、Auto(Round-Robin 轮转)、指定代理
|
| 14 |
+
- 健康检查:定时(默认 5 分钟)+ 手动,通过 ipify API 获取出口 IP 和延迟
|
| 15 |
+
- 不可达代理自动标记为 unreachable,不参与自动轮转
|
| 16 |
+
- Dashboard 代理池管理面板:添加/删除/检查/启用/禁用代理
|
| 17 |
+
- AccountCard 代理选择器:每个账号可选择代理或模式
|
| 18 |
+
- 全套 REST API:`/api/proxies` CRUD + `/api/proxies/assign` 分配管理
|
| 19 |
+
- 持久化:`data/proxies.json`(原子写入,与 cookies.json 同模式)
|
| 20 |
+
- Transport 层支持 per-request 代理:`TlsTransport` 接口新增可选 `proxyUrl` 参数
|
| 21 |
- Dashboard GitHub Star 徽章:Header 新增醒目的 ⭐ Star 按钮(amber 药丸样式),点击跳转 GitHub 仓库页面,方便用户收藏和获取更新
|
| 22 |
- Dashboard 检查更新功能:Footer 显示 Proxy 版本+commit 和 Codex Desktop 指纹版本,提供"检查更新"按钮同时检查两种更新
|
| 23 |
- Proxy 自更新(CLI 模式):通过 `git fetch` 检查新提交,自动执行 `git pull + npm install + npm run build`,完成后提示重启
|
shared/hooks/use-proxies.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from "preact/hooks";
|
| 2 |
+
import type { ProxyEntry, ProxyAssignment } from "../types";
|
| 3 |
+
|
| 4 |
+
export interface ProxiesState {
|
| 5 |
+
proxies: ProxyEntry[];
|
| 6 |
+
assignments: ProxyAssignment[];
|
| 7 |
+
healthCheckIntervalMinutes: number;
|
| 8 |
+
loading: boolean;
|
| 9 |
+
refresh: () => Promise<void>;
|
| 10 |
+
addProxy: (name: string, url: string) => Promise<string | null>;
|
| 11 |
+
removeProxy: (id: string) => Promise<string | null>;
|
| 12 |
+
checkProxy: (id: string) => Promise<void>;
|
| 13 |
+
checkAll: () => Promise<void>;
|
| 14 |
+
enableProxy: (id: string) => Promise<void>;
|
| 15 |
+
disableProxy: (id: string) => Promise<void>;
|
| 16 |
+
assignProxy: (accountId: string, proxyId: string) => Promise<void>;
|
| 17 |
+
unassignProxy: (accountId: string) => Promise<void>;
|
| 18 |
+
setInterval: (minutes: number) => Promise<void>;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function useProxies(): ProxiesState {
|
| 22 |
+
const [proxies, setProxies] = useState<ProxyEntry[]>([]);
|
| 23 |
+
const [assignments, setAssignments] = useState<ProxyAssignment[]>([]);
|
| 24 |
+
const [healthCheckIntervalMinutes, setHealthInterval] = useState(5);
|
| 25 |
+
const [loading, setLoading] = useState(true);
|
| 26 |
+
|
| 27 |
+
const refresh = useCallback(async () => {
|
| 28 |
+
try {
|
| 29 |
+
const resp = await fetch("/api/proxies");
|
| 30 |
+
const data = await resp.json();
|
| 31 |
+
setProxies(data.proxies || []);
|
| 32 |
+
setAssignments(data.assignments || []);
|
| 33 |
+
setHealthInterval(data.healthCheckIntervalMinutes || 5);
|
| 34 |
+
} catch {
|
| 35 |
+
// ignore
|
| 36 |
+
} finally {
|
| 37 |
+
setLoading(false);
|
| 38 |
+
}
|
| 39 |
+
}, []);
|
| 40 |
+
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
refresh();
|
| 43 |
+
}, [refresh]);
|
| 44 |
+
|
| 45 |
+
const addProxy = useCallback(
|
| 46 |
+
async (name: string, url: string): Promise<string | null> => {
|
| 47 |
+
try {
|
| 48 |
+
const resp = await fetch("/api/proxies", {
|
| 49 |
+
method: "POST",
|
| 50 |
+
headers: { "Content-Type": "application/json" },
|
| 51 |
+
body: JSON.stringify({ name, url }),
|
| 52 |
+
});
|
| 53 |
+
const data = await resp.json();
|
| 54 |
+
if (!resp.ok) return data.error || "Failed to add proxy";
|
| 55 |
+
await refresh();
|
| 56 |
+
return null;
|
| 57 |
+
} catch (err) {
|
| 58 |
+
return err instanceof Error ? err.message : "Network error";
|
| 59 |
+
}
|
| 60 |
+
},
|
| 61 |
+
[refresh],
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
const removeProxy = useCallback(
|
| 65 |
+
async (id: string): Promise<string | null> => {
|
| 66 |
+
try {
|
| 67 |
+
const resp = await fetch(`/api/proxies/${encodeURIComponent(id)}`, {
|
| 68 |
+
method: "DELETE",
|
| 69 |
+
});
|
| 70 |
+
if (!resp.ok) {
|
| 71 |
+
const data = await resp.json();
|
| 72 |
+
return data.error || "Failed to remove proxy";
|
| 73 |
+
}
|
| 74 |
+
await refresh();
|
| 75 |
+
return null;
|
| 76 |
+
} catch (err) {
|
| 77 |
+
return err instanceof Error ? err.message : "Network error";
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
[refresh],
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
const checkProxy = useCallback(
|
| 84 |
+
async (id: string) => {
|
| 85 |
+
await fetch(`/api/proxies/${encodeURIComponent(id)}/check`, {
|
| 86 |
+
method: "POST",
|
| 87 |
+
});
|
| 88 |
+
await refresh();
|
| 89 |
+
},
|
| 90 |
+
[refresh],
|
| 91 |
+
);
|
| 92 |
+
|
| 93 |
+
const checkAll = useCallback(async () => {
|
| 94 |
+
await fetch("/api/proxies/check-all", { method: "POST" });
|
| 95 |
+
await refresh();
|
| 96 |
+
}, [refresh]);
|
| 97 |
+
|
| 98 |
+
const enableProxy = useCallback(
|
| 99 |
+
async (id: string) => {
|
| 100 |
+
await fetch(`/api/proxies/${encodeURIComponent(id)}/enable`, {
|
| 101 |
+
method: "POST",
|
| 102 |
+
});
|
| 103 |
+
await refresh();
|
| 104 |
+
},
|
| 105 |
+
[refresh],
|
| 106 |
+
);
|
| 107 |
+
|
| 108 |
+
const disableProxy = useCallback(
|
| 109 |
+
async (id: string) => {
|
| 110 |
+
await fetch(`/api/proxies/${encodeURIComponent(id)}/disable`, {
|
| 111 |
+
method: "POST",
|
| 112 |
+
});
|
| 113 |
+
await refresh();
|
| 114 |
+
},
|
| 115 |
+
[refresh],
|
| 116 |
+
);
|
| 117 |
+
|
| 118 |
+
const assignProxy = useCallback(
|
| 119 |
+
async (accountId: string, proxyId: string) => {
|
| 120 |
+
await fetch("/api/proxies/assign", {
|
| 121 |
+
method: "POST",
|
| 122 |
+
headers: { "Content-Type": "application/json" },
|
| 123 |
+
body: JSON.stringify({ accountId, proxyId }),
|
| 124 |
+
});
|
| 125 |
+
await refresh();
|
| 126 |
+
},
|
| 127 |
+
[refresh],
|
| 128 |
+
);
|
| 129 |
+
|
| 130 |
+
const unassignProxy = useCallback(
|
| 131 |
+
async (accountId: string) => {
|
| 132 |
+
await fetch(`/api/proxies/assign/${encodeURIComponent(accountId)}`, {
|
| 133 |
+
method: "DELETE",
|
| 134 |
+
});
|
| 135 |
+
await refresh();
|
| 136 |
+
},
|
| 137 |
+
[refresh],
|
| 138 |
+
);
|
| 139 |
+
|
| 140 |
+
const setIntervalMinutes = useCallback(
|
| 141 |
+
async (minutes: number) => {
|
| 142 |
+
await fetch("/api/proxies/settings", {
|
| 143 |
+
method: "PUT",
|
| 144 |
+
headers: { "Content-Type": "application/json" },
|
| 145 |
+
body: JSON.stringify({ healthCheckIntervalMinutes: minutes }),
|
| 146 |
+
});
|
| 147 |
+
await refresh();
|
| 148 |
+
},
|
| 149 |
+
[refresh],
|
| 150 |
+
);
|
| 151 |
+
|
| 152 |
+
return {
|
| 153 |
+
proxies,
|
| 154 |
+
assignments,
|
| 155 |
+
healthCheckIntervalMinutes,
|
| 156 |
+
loading,
|
| 157 |
+
refresh,
|
| 158 |
+
addProxy,
|
| 159 |
+
removeProxy,
|
| 160 |
+
checkProxy,
|
| 161 |
+
checkAll,
|
| 162 |
+
enableProxy,
|
| 163 |
+
disableProxy,
|
| 164 |
+
assignProxy,
|
| 165 |
+
unassignProxy,
|
| 166 |
+
setInterval: setIntervalMinutes,
|
| 167 |
+
};
|
| 168 |
+
}
|
shared/i18n/translations.ts
CHANGED
|
@@ -72,6 +72,29 @@ export const translations = {
|
|
| 72 |
lastChecked: "Last checked",
|
| 73 |
never: "never",
|
| 74 |
commits: "commits",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
},
|
| 76 |
zh: {
|
| 77 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
@@ -149,6 +172,29 @@ export const translations = {
|
|
| 149 |
lastChecked: "\u4e0a\u6b21\u68c0\u67e5",
|
| 150 |
never: "\u4ece\u672a",
|
| 151 |
commits: "\u4e2a\u63d0\u4ea4",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
},
|
| 153 |
} as const;
|
| 154 |
|
|
|
|
| 72 |
lastChecked: "Last checked",
|
| 73 |
never: "never",
|
| 74 |
commits: "commits",
|
| 75 |
+
proxyPool: "Proxy Pool",
|
| 76 |
+
proxyPoolDesc: "Manage upstream proxies for different accounts.",
|
| 77 |
+
addProxy: "Add Proxy",
|
| 78 |
+
proxyName: "Name",
|
| 79 |
+
proxyUrl: "Proxy URL",
|
| 80 |
+
noProxies: "No proxies configured. Add one to route accounts through different IPs.",
|
| 81 |
+
proxyActive: "Active",
|
| 82 |
+
proxyUnreachable: "Unreachable",
|
| 83 |
+
proxyDisabled: "Disabled",
|
| 84 |
+
exitIp: "Exit IP",
|
| 85 |
+
latency: "Latency",
|
| 86 |
+
checkHealth: "Check",
|
| 87 |
+
checkAllHealth: "Check All",
|
| 88 |
+
enableProxy: "Enable",
|
| 89 |
+
disableProxy: "Disable",
|
| 90 |
+
deleteProxy: "Delete proxy",
|
| 91 |
+
removeProxyConfirm: "Remove this proxy?",
|
| 92 |
+
proxyAssignment: "Proxy",
|
| 93 |
+
globalDefault: "Global Default",
|
| 94 |
+
directNoProxy: "Direct (No Proxy)",
|
| 95 |
+
autoRoundRobin: "Auto (Round-Robin)",
|
| 96 |
+
healthInterval: "Health check interval",
|
| 97 |
+
minutes: "min",
|
| 98 |
},
|
| 99 |
zh: {
|
| 100 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
|
|
| 172 |
lastChecked: "\u4e0a\u6b21\u68c0\u67e5",
|
| 173 |
never: "\u4ece\u672a",
|
| 174 |
commits: "\u4e2a\u63d0\u4ea4",
|
| 175 |
+
proxyPool: "\u4ee3\u7406\u6c60",
|
| 176 |
+
proxyPoolDesc: "\u7ba1\u7406\u4e0d\u540c\u8d26\u53f7\u7684\u4e0a\u6e38\u4ee3\u7406\u3002",
|
| 177 |
+
addProxy: "\u6dfb\u52a0\u4ee3\u7406",
|
| 178 |
+
proxyName: "\u540d\u79f0",
|
| 179 |
+
proxyUrl: "\u4ee3\u7406 URL",
|
| 180 |
+
noProxies: "\u672a\u914d\u7f6e\u4ee3\u7406\u3002\u6dfb\u52a0\u4e00\u4e2a\u4ee5\u901a\u8fc7\u4e0d\u540c IP \u8def\u7531\u8d26\u53f7\u3002",
|
| 181 |
+
proxyActive: "\u6d3b\u8dc3",
|
| 182 |
+
proxyUnreachable: "\u4e0d\u53ef\u8fbe",
|
| 183 |
+
proxyDisabled: "\u5df2\u7981\u7528",
|
| 184 |
+
exitIp: "\u51fa\u53e3 IP",
|
| 185 |
+
latency: "\u5ef6\u8fdf",
|
| 186 |
+
checkHealth: "\u68c0\u67e5",
|
| 187 |
+
checkAllHealth: "\u68c0\u67e5\u5168\u90e8",
|
| 188 |
+
enableProxy: "\u542f\u7528",
|
| 189 |
+
disableProxy: "\u7981\u7528",
|
| 190 |
+
deleteProxy: "\u5220\u9664\u4ee3\u7406",
|
| 191 |
+
removeProxyConfirm: "\u786e\u5b9a\u8981\u79fb\u9664\u6b64\u4ee3\u7406\u5417\uff1f",
|
| 192 |
+
proxyAssignment: "\u4ee3\u7406",
|
| 193 |
+
globalDefault: "\u5168\u5c40\u9ed8\u8ba4",
|
| 194 |
+
directNoProxy: "\u76f4\u8fde\uff08\u65e0\u4ee3\u7406\uff09",
|
| 195 |
+
autoRoundRobin: "\u81ea\u52a8\u8f6e\u8f6c",
|
| 196 |
+
healthInterval: "\u5065\u5eb7\u68c0\u67e5\u95f4\u9694",
|
| 197 |
+
minutes: "\u5206\u949f",
|
| 198 |
},
|
| 199 |
} as const;
|
| 200 |
|
shared/types.ts
CHANGED
|
@@ -21,4 +21,27 @@ export interface Account {
|
|
| 21 |
window_output_tokens?: number;
|
| 22 |
};
|
| 23 |
quota?: AccountQuota;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
|
|
|
| 21 |
window_output_tokens?: number;
|
| 22 |
};
|
| 23 |
quota?: AccountQuota;
|
| 24 |
+
proxyId?: string;
|
| 25 |
+
proxyName?: string;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export interface ProxyHealthInfo {
|
| 29 |
+
exitIp: string | null;
|
| 30 |
+
latencyMs: number;
|
| 31 |
+
lastChecked: string;
|
| 32 |
+
error: string | null;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export interface ProxyEntry {
|
| 36 |
+
id: string;
|
| 37 |
+
name: string;
|
| 38 |
+
url: string;
|
| 39 |
+
status: "active" | "unreachable" | "disabled";
|
| 40 |
+
health: ProxyHealthInfo | null;
|
| 41 |
+
addedAt: string;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export interface ProxyAssignment {
|
| 45 |
+
accountId: string;
|
| 46 |
+
proxyId: string;
|
| 47 |
}
|
src/index.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { createGeminiRoutes } from "./routes/gemini.js";
|
|
| 15 |
import { createModelRoutes } from "./routes/models.js";
|
| 16 |
import { createWebRoutes } from "./routes/web.js";
|
| 17 |
import { CookieJar } from "./proxy/cookie-jar.js";
|
|
|
|
|
|
|
| 18 |
import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
|
| 19 |
import { initProxy } from "./tls/curl-binary.js";
|
| 20 |
import { initTransport } from "./tls/transport.js";
|
|
@@ -54,6 +56,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 54 |
const accountPool = new AccountPool();
|
| 55 |
const refreshScheduler = new RefreshScheduler(accountPool);
|
| 56 |
const cookieJar = new CookieJar();
|
|
|
|
| 57 |
|
| 58 |
// Create Hono app
|
| 59 |
const app = new Hono();
|
|
@@ -65,10 +68,11 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 65 |
|
| 66 |
// Mount routes
|
| 67 |
const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
|
| 68 |
-
const accountRoutes = createAccountRoutes(accountPool, refreshScheduler, cookieJar);
|
| 69 |
-
const chatRoutes = createChatRoutes(accountPool, cookieJar);
|
| 70 |
-
const messagesRoutes = createMessagesRoutes(accountPool, cookieJar);
|
| 71 |
-
const geminiRoutes = createGeminiRoutes(accountPool, cookieJar);
|
|
|
|
| 72 |
const webRoutes = createWebRoutes(accountPool);
|
| 73 |
|
| 74 |
app.route("/", authRoutes);
|
|
@@ -76,6 +80,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 76 |
app.route("/", chatRoutes);
|
| 77 |
app.route("/", messagesRoutes);
|
| 78 |
app.route("/", geminiRoutes);
|
|
|
|
| 79 |
app.route("/", createModelRoutes());
|
| 80 |
app.route("/", webRoutes);
|
| 81 |
|
|
@@ -111,7 +116,10 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 111 |
startUpdateChecker();
|
| 112 |
|
| 113 |
// Start background model refresh (requires auth to be ready)
|
| 114 |
-
startModelRefresh(accountPool, cookieJar);
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
const server = serve({
|
| 117 |
fetch: app.fetch,
|
|
@@ -125,6 +133,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
|
|
| 125 |
stopUpdateChecker();
|
| 126 |
stopModelRefresh();
|
| 127 |
refreshScheduler.destroy();
|
|
|
|
| 128 |
cookieJar.destroy();
|
| 129 |
accountPool.destroy();
|
| 130 |
resolve();
|
|
|
|
| 15 |
import { createModelRoutes } from "./routes/models.js";
|
| 16 |
import { createWebRoutes } from "./routes/web.js";
|
| 17 |
import { CookieJar } from "./proxy/cookie-jar.js";
|
| 18 |
+
import { ProxyPool } from "./proxy/proxy-pool.js";
|
| 19 |
+
import { createProxyRoutes } from "./routes/proxies.js";
|
| 20 |
import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
|
| 21 |
import { initProxy } from "./tls/curl-binary.js";
|
| 22 |
import { initTransport } from "./tls/transport.js";
|
|
|
|
| 56 |
const accountPool = new AccountPool();
|
| 57 |
const refreshScheduler = new RefreshScheduler(accountPool);
|
| 58 |
const cookieJar = new CookieJar();
|
| 59 |
+
const proxyPool = new ProxyPool();
|
| 60 |
|
| 61 |
// Create Hono app
|
| 62 |
const app = new Hono();
|
|
|
|
| 68 |
|
| 69 |
// Mount routes
|
| 70 |
const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
|
| 71 |
+
const accountRoutes = createAccountRoutes(accountPool, refreshScheduler, cookieJar, proxyPool);
|
| 72 |
+
const chatRoutes = createChatRoutes(accountPool, cookieJar, proxyPool);
|
| 73 |
+
const messagesRoutes = createMessagesRoutes(accountPool, cookieJar, proxyPool);
|
| 74 |
+
const geminiRoutes = createGeminiRoutes(accountPool, cookieJar, proxyPool);
|
| 75 |
+
const proxyRoutes = createProxyRoutes(proxyPool);
|
| 76 |
const webRoutes = createWebRoutes(accountPool);
|
| 77 |
|
| 78 |
app.route("/", authRoutes);
|
|
|
|
| 80 |
app.route("/", chatRoutes);
|
| 81 |
app.route("/", messagesRoutes);
|
| 82 |
app.route("/", geminiRoutes);
|
| 83 |
+
app.route("/", proxyRoutes);
|
| 84 |
app.route("/", createModelRoutes());
|
| 85 |
app.route("/", webRoutes);
|
| 86 |
|
|
|
|
| 116 |
startUpdateChecker();
|
| 117 |
|
| 118 |
// Start background model refresh (requires auth to be ready)
|
| 119 |
+
startModelRefresh(accountPool, cookieJar, proxyPool);
|
| 120 |
+
|
| 121 |
+
// Start proxy health check timer (if proxies exist)
|
| 122 |
+
proxyPool.startHealthCheckTimer();
|
| 123 |
|
| 124 |
const server = serve({
|
| 125 |
fetch: app.fetch,
|
|
|
|
| 133 |
stopUpdateChecker();
|
| 134 |
stopModelRefresh();
|
| 135 |
refreshScheduler.destroy();
|
| 136 |
+
proxyPool.destroy();
|
| 137 |
cookieJar.destroy();
|
| 138 |
accountPool.destroy();
|
| 139 |
resolve();
|
src/models/model-fetcher.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { CodexApi } from "../proxy/codex-api.js";
|
|
| 10 |
import { applyBackendModels, type BackendModelEntry } from "./model-store.js";
|
| 11 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 12 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
|
|
|
| 13 |
import { jitter } from "../utils/jitter.js";
|
| 14 |
|
| 15 |
const REFRESH_INTERVAL_HOURS = 1;
|
|
@@ -18,6 +19,7 @@ const INITIAL_DELAY_MS = 5_000; // 5s after startup
|
|
| 18 |
let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
| 19 |
let _accountPool: AccountPool | null = null;
|
| 20 |
let _cookieJar: CookieJar | null = null;
|
|
|
|
| 21 |
|
| 22 |
/**
|
| 23 |
* Fetch models from the Codex backend using an available account.
|
|
@@ -25,6 +27,7 @@ let _cookieJar: CookieJar | null = null;
|
|
| 25 |
async function fetchModelsFromBackend(
|
| 26 |
accountPool: AccountPool,
|
| 27 |
cookieJar: CookieJar,
|
|
|
|
| 28 |
): Promise<void> {
|
| 29 |
if (!accountPool.isAuthenticated()) return; // silently skip when no accounts
|
| 30 |
|
|
@@ -35,11 +38,13 @@ async function fetchModelsFromBackend(
|
|
| 35 |
}
|
| 36 |
|
| 37 |
try {
|
|
|
|
| 38 |
const api = new CodexApi(
|
| 39 |
acquired.token,
|
| 40 |
acquired.accountId,
|
| 41 |
cookieJar,
|
| 42 |
acquired.entryId,
|
|
|
|
| 43 |
);
|
| 44 |
|
| 45 |
const models = await api.getModels();
|
|
@@ -65,14 +70,16 @@ async function fetchModelsFromBackend(
|
|
| 65 |
export function startModelRefresh(
|
| 66 |
accountPool: AccountPool,
|
| 67 |
cookieJar: CookieJar,
|
|
|
|
| 68 |
): void {
|
| 69 |
_accountPool = accountPool;
|
| 70 |
_cookieJar = cookieJar;
|
|
|
|
| 71 |
|
| 72 |
// Initial fetch after short delay
|
| 73 |
_refreshTimer = setTimeout(async () => {
|
| 74 |
try {
|
| 75 |
-
await fetchModelsFromBackend(accountPool, cookieJar);
|
| 76 |
} finally {
|
| 77 |
scheduleNext(accountPool, cookieJar);
|
| 78 |
}
|
|
@@ -88,7 +95,7 @@ function scheduleNext(
|
|
| 88 |
const intervalMs = jitter(REFRESH_INTERVAL_HOURS * 3600 * 1000, 0.15);
|
| 89 |
_refreshTimer = setTimeout(async () => {
|
| 90 |
try {
|
| 91 |
-
await fetchModelsFromBackend(accountPool, cookieJar);
|
| 92 |
} finally {
|
| 93 |
scheduleNext(accountPool, cookieJar);
|
| 94 |
}
|
|
@@ -101,7 +108,7 @@ function scheduleNext(
|
|
| 101 |
*/
|
| 102 |
export function triggerImmediateRefresh(): void {
|
| 103 |
if (_accountPool && _cookieJar) {
|
| 104 |
-
fetchModelsFromBackend(_accountPool, _cookieJar).catch((err) => {
|
| 105 |
const msg = err instanceof Error ? err.message : String(err);
|
| 106 |
console.warn(`[ModelFetcher] Immediate refresh failed: ${msg}`);
|
| 107 |
});
|
|
|
|
| 10 |
import { applyBackendModels, type BackendModelEntry } from "./model-store.js";
|
| 11 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 12 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
| 13 |
+
import type { ProxyPool } from "../proxy/proxy-pool.js";
|
| 14 |
import { jitter } from "../utils/jitter.js";
|
| 15 |
|
| 16 |
const REFRESH_INTERVAL_HOURS = 1;
|
|
|
|
| 19 |
let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
| 20 |
let _accountPool: AccountPool | null = null;
|
| 21 |
let _cookieJar: CookieJar | null = null;
|
| 22 |
+
let _proxyPool: ProxyPool | null = null;
|
| 23 |
|
| 24 |
/**
|
| 25 |
* Fetch models from the Codex backend using an available account.
|
|
|
|
| 27 |
async function fetchModelsFromBackend(
|
| 28 |
accountPool: AccountPool,
|
| 29 |
cookieJar: CookieJar,
|
| 30 |
+
proxyPool: ProxyPool | null,
|
| 31 |
): Promise<void> {
|
| 32 |
if (!accountPool.isAuthenticated()) return; // silently skip when no accounts
|
| 33 |
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
try {
|
| 41 |
+
const proxyUrl = proxyPool?.resolveProxyUrl(acquired.entryId);
|
| 42 |
const api = new CodexApi(
|
| 43 |
acquired.token,
|
| 44 |
acquired.accountId,
|
| 45 |
cookieJar,
|
| 46 |
acquired.entryId,
|
| 47 |
+
proxyUrl,
|
| 48 |
);
|
| 49 |
|
| 50 |
const models = await api.getModels();
|
|
|
|
| 70 |
export function startModelRefresh(
|
| 71 |
accountPool: AccountPool,
|
| 72 |
cookieJar: CookieJar,
|
| 73 |
+
proxyPool?: ProxyPool,
|
| 74 |
): void {
|
| 75 |
_accountPool = accountPool;
|
| 76 |
_cookieJar = cookieJar;
|
| 77 |
+
_proxyPool = proxyPool ?? null;
|
| 78 |
|
| 79 |
// Initial fetch after short delay
|
| 80 |
_refreshTimer = setTimeout(async () => {
|
| 81 |
try {
|
| 82 |
+
await fetchModelsFromBackend(accountPool, cookieJar, _proxyPool);
|
| 83 |
} finally {
|
| 84 |
scheduleNext(accountPool, cookieJar);
|
| 85 |
}
|
|
|
|
| 95 |
const intervalMs = jitter(REFRESH_INTERVAL_HOURS * 3600 * 1000, 0.15);
|
| 96 |
_refreshTimer = setTimeout(async () => {
|
| 97 |
try {
|
| 98 |
+
await fetchModelsFromBackend(accountPool, cookieJar, _proxyPool);
|
| 99 |
} finally {
|
| 100 |
scheduleNext(accountPool, cookieJar);
|
| 101 |
}
|
|
|
|
| 108 |
*/
|
| 109 |
export function triggerImmediateRefresh(): void {
|
| 110 |
if (_accountPool && _cookieJar) {
|
| 111 |
+
fetchModelsFromBackend(_accountPool, _cookieJar, _proxyPool).catch((err) => {
|
| 112 |
const msg = err instanceof Error ? err.message : String(err);
|
| 113 |
console.warn(`[ModelFetcher] Immediate refresh failed: ${msg}`);
|
| 114 |
});
|
src/proxy/codex-api.ts
CHANGED
|
@@ -57,17 +57,20 @@ export class CodexApi {
|
|
| 57 |
private accountId: string | null;
|
| 58 |
private cookieJar: CookieJar | null;
|
| 59 |
private entryId: string | null;
|
|
|
|
| 60 |
|
| 61 |
constructor(
|
| 62 |
token: string,
|
| 63 |
accountId: string | null,
|
| 64 |
cookieJar?: CookieJar | null,
|
| 65 |
entryId?: string | null,
|
|
|
|
| 66 |
) {
|
| 67 |
this.token = token;
|
| 68 |
this.accountId = accountId;
|
| 69 |
this.cookieJar = cookieJar ?? null;
|
| 70 |
this.entryId = entryId ?? null;
|
|
|
|
| 71 |
}
|
| 72 |
|
| 73 |
setToken(token: string): void {
|
|
@@ -111,7 +114,7 @@ export class CodexApi {
|
|
| 111 |
|
| 112 |
let body: string;
|
| 113 |
try {
|
| 114 |
-
const result = await transport.get(url, headers, 15);
|
| 115 |
body = result.body;
|
| 116 |
} catch (err) {
|
| 117 |
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -157,7 +160,7 @@ export class CodexApi {
|
|
| 157 |
|
| 158 |
for (const url of endpoints) {
|
| 159 |
try {
|
| 160 |
-
const result = await transport.get(url, headers, 15);
|
| 161 |
const parsed = JSON.parse(result.body) as Record<string, unknown>;
|
| 162 |
|
| 163 |
// sentinel/chat-requirements returns { chat_models: { models: [...], ... } }
|
|
@@ -219,7 +222,7 @@ export class CodexApi {
|
|
| 219 |
}
|
| 220 |
|
| 221 |
try {
|
| 222 |
-
const result = await transport.get(url, headers, 15);
|
| 223 |
return JSON.parse(result.body) as Record<string, unknown>;
|
| 224 |
} catch {
|
| 225 |
return null;
|
|
@@ -248,7 +251,7 @@ export class CodexApi {
|
|
| 248 |
|
| 249 |
let transportRes;
|
| 250 |
try {
|
| 251 |
-
transportRes = await transport.post(url, headers, JSON.stringify(request), signal, timeout);
|
| 252 |
} catch (err) {
|
| 253 |
const msg = err instanceof Error ? err.message : String(err);
|
| 254 |
throw new CodexApiError(0, msg);
|
|
|
|
| 57 |
private accountId: string | null;
|
| 58 |
private cookieJar: CookieJar | null;
|
| 59 |
private entryId: string | null;
|
| 60 |
+
private proxyUrl: string | null | undefined;
|
| 61 |
|
| 62 |
constructor(
|
| 63 |
token: string,
|
| 64 |
accountId: string | null,
|
| 65 |
cookieJar?: CookieJar | null,
|
| 66 |
entryId?: string | null,
|
| 67 |
+
proxyUrl?: string | null,
|
| 68 |
) {
|
| 69 |
this.token = token;
|
| 70 |
this.accountId = accountId;
|
| 71 |
this.cookieJar = cookieJar ?? null;
|
| 72 |
this.entryId = entryId ?? null;
|
| 73 |
+
this.proxyUrl = proxyUrl;
|
| 74 |
}
|
| 75 |
|
| 76 |
setToken(token: string): void {
|
|
|
|
| 114 |
|
| 115 |
let body: string;
|
| 116 |
try {
|
| 117 |
+
const result = await transport.get(url, headers, 15, this.proxyUrl);
|
| 118 |
body = result.body;
|
| 119 |
} catch (err) {
|
| 120 |
const msg = err instanceof Error ? err.message : String(err);
|
|
|
|
| 160 |
|
| 161 |
for (const url of endpoints) {
|
| 162 |
try {
|
| 163 |
+
const result = await transport.get(url, headers, 15, this.proxyUrl);
|
| 164 |
const parsed = JSON.parse(result.body) as Record<string, unknown>;
|
| 165 |
|
| 166 |
// sentinel/chat-requirements returns { chat_models: { models: [...], ... } }
|
|
|
|
| 222 |
}
|
| 223 |
|
| 224 |
try {
|
| 225 |
+
const result = await transport.get(url, headers, 15, this.proxyUrl);
|
| 226 |
return JSON.parse(result.body) as Record<string, unknown>;
|
| 227 |
} catch {
|
| 228 |
return null;
|
|
|
|
| 251 |
|
| 252 |
let transportRes;
|
| 253 |
try {
|
| 254 |
+
transportRes = await transport.post(url, headers, JSON.stringify(request), signal, timeout, this.proxyUrl);
|
| 255 |
} catch (err) {
|
| 256 |
const msg = err instanceof Error ? err.message : String(err);
|
| 257 |
throw new CodexApiError(0, msg);
|
src/proxy/proxy-pool.ts
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ProxyPool — per-account proxy management with health checks.
|
| 3 |
+
*
|
| 4 |
+
* Stores proxy entries and account→proxy assignments.
|
| 5 |
+
* Supports manual assignment, "auto" round-robin, "direct" (no proxy),
|
| 6 |
+
* and "global" (use the globally detected proxy).
|
| 7 |
+
*
|
| 8 |
+
* Persistence: data/proxies.json (atomic write via tmp + rename).
|
| 9 |
+
* Health checks: periodic + on-demand, using api.ipify.org for exit IP.
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import {
|
| 13 |
+
readFileSync,
|
| 14 |
+
writeFileSync,
|
| 15 |
+
renameSync,
|
| 16 |
+
existsSync,
|
| 17 |
+
mkdirSync,
|
| 18 |
+
} from "fs";
|
| 19 |
+
import { resolve, dirname } from "path";
|
| 20 |
+
import { getDataDir } from "../paths.js";
|
| 21 |
+
import { getTransport } from "../tls/transport.js";
|
| 22 |
+
|
| 23 |
+
function getProxiesFile(): string {
|
| 24 |
+
return resolve(getDataDir(), "proxies.json");
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// ── Types ─────────────────────────────────────────────────────────────
|
| 28 |
+
|
| 29 |
+
export interface ProxyHealthInfo {
|
| 30 |
+
exitIp: string | null;
|
| 31 |
+
latencyMs: number;
|
| 32 |
+
lastChecked: string;
|
| 33 |
+
error: string | null;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export type ProxyStatus = "active" | "unreachable" | "disabled";
|
| 37 |
+
|
| 38 |
+
export interface ProxyEntry {
|
| 39 |
+
id: string;
|
| 40 |
+
name: string;
|
| 41 |
+
url: string;
|
| 42 |
+
status: ProxyStatus;
|
| 43 |
+
health: ProxyHealthInfo | null;
|
| 44 |
+
addedAt: string;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/** Special assignment values (not a proxy ID). */
|
| 48 |
+
export type SpecialAssignment = "global" | "direct" | "auto";
|
| 49 |
+
|
| 50 |
+
export interface ProxyAssignment {
|
| 51 |
+
accountId: string;
|
| 52 |
+
proxyId: string; // ProxyEntry.id | SpecialAssignment
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
interface ProxiesFile {
|
| 56 |
+
proxies: ProxyEntry[];
|
| 57 |
+
assignments: ProxyAssignment[];
|
| 58 |
+
healthCheckIntervalMinutes: number;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const HEALTH_CHECK_URL = "https://api.ipify.org?format=json";
|
| 62 |
+
const DEFAULT_HEALTH_INTERVAL_MIN = 5;
|
| 63 |
+
|
| 64 |
+
// ── ProxyPool ─────────────────────────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
export class ProxyPool {
|
| 67 |
+
private proxies: Map<string, ProxyEntry> = new Map();
|
| 68 |
+
private assignments: Map<string, string> = new Map(); // accountId → proxyId
|
| 69 |
+
private healthIntervalMin = DEFAULT_HEALTH_INTERVAL_MIN;
|
| 70 |
+
private persistTimer: ReturnType<typeof setTimeout> | null = null;
|
| 71 |
+
private healthTimer: ReturnType<typeof setInterval> | null = null;
|
| 72 |
+
private _roundRobinIndex = 0;
|
| 73 |
+
|
| 74 |
+
constructor() {
|
| 75 |
+
this.load();
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// ── CRUD ──────────────────────────────────────────────────────────
|
| 79 |
+
|
| 80 |
+
add(name: string, url: string): string {
|
| 81 |
+
const id = randomHex(8);
|
| 82 |
+
const entry: ProxyEntry = {
|
| 83 |
+
id,
|
| 84 |
+
name: name.trim(),
|
| 85 |
+
url: url.trim(),
|
| 86 |
+
status: "active",
|
| 87 |
+
health: null,
|
| 88 |
+
addedAt: new Date().toISOString(),
|
| 89 |
+
};
|
| 90 |
+
this.proxies.set(id, entry);
|
| 91 |
+
this.persistNow();
|
| 92 |
+
return id;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
remove(id: string): boolean {
|
| 96 |
+
if (!this.proxies.delete(id)) return false;
|
| 97 |
+
// Clean up assignments pointing to this proxy
|
| 98 |
+
for (const [accountId, proxyId] of this.assignments) {
|
| 99 |
+
if (proxyId === id) {
|
| 100 |
+
this.assignments.delete(accountId);
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
this.persistNow();
|
| 104 |
+
return true;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
update(id: string, fields: { name?: string; url?: string }): boolean {
|
| 108 |
+
const entry = this.proxies.get(id);
|
| 109 |
+
if (!entry) return false;
|
| 110 |
+
if (fields.name !== undefined) entry.name = fields.name.trim();
|
| 111 |
+
if (fields.url !== undefined) {
|
| 112 |
+
entry.url = fields.url.trim();
|
| 113 |
+
entry.health = null; // reset health on URL change
|
| 114 |
+
entry.status = "active";
|
| 115 |
+
}
|
| 116 |
+
this.schedulePersist();
|
| 117 |
+
return true;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
getAll(): ProxyEntry[] {
|
| 121 |
+
return Array.from(this.proxies.values());
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
getById(id: string): ProxyEntry | undefined {
|
| 125 |
+
return this.proxies.get(id);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
enable(id: string): boolean {
|
| 129 |
+
const entry = this.proxies.get(id);
|
| 130 |
+
if (!entry) return false;
|
| 131 |
+
entry.status = "active";
|
| 132 |
+
this.schedulePersist();
|
| 133 |
+
return true;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
disable(id: string): boolean {
|
| 137 |
+
const entry = this.proxies.get(id);
|
| 138 |
+
if (!entry) return false;
|
| 139 |
+
entry.status = "disabled";
|
| 140 |
+
this.schedulePersist();
|
| 141 |
+
return true;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// ── Assignment ────────────────────────────────────────────────────
|
| 145 |
+
|
| 146 |
+
assign(accountId: string, proxyId: string): void {
|
| 147 |
+
this.assignments.set(accountId, proxyId);
|
| 148 |
+
this.persistNow();
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
unassign(accountId: string): void {
|
| 152 |
+
if (this.assignments.delete(accountId)) {
|
| 153 |
+
this.persistNow();
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
getAssignment(accountId: string): string {
|
| 158 |
+
return this.assignments.get(accountId) ?? "global";
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
getAllAssignments(): ProxyAssignment[] {
|
| 162 |
+
const result: ProxyAssignment[] = [];
|
| 163 |
+
for (const [accountId, proxyId] of this.assignments) {
|
| 164 |
+
result.push({ accountId, proxyId });
|
| 165 |
+
}
|
| 166 |
+
return result;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/**
|
| 170 |
+
* Get display name for an assignment.
|
| 171 |
+
*/
|
| 172 |
+
getAssignmentDisplayName(accountId: string): string {
|
| 173 |
+
const assignment = this.getAssignment(accountId);
|
| 174 |
+
if (assignment === "global") return "Global Default";
|
| 175 |
+
if (assignment === "direct") return "Direct (No Proxy)";
|
| 176 |
+
if (assignment === "auto") return "Auto (Round-Robin)";
|
| 177 |
+
const proxy = this.proxies.get(assignment);
|
| 178 |
+
return proxy ? proxy.name : "Unknown Proxy";
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// ── Resolution ────────────────────────────────────────────────────
|
| 182 |
+
|
| 183 |
+
/**
|
| 184 |
+
* Resolve the proxy URL for an account.
|
| 185 |
+
* Returns:
|
| 186 |
+
* undefined — use global proxy (default behavior)
|
| 187 |
+
* null — direct connection (no proxy)
|
| 188 |
+
* string — specific proxy URL
|
| 189 |
+
*/
|
| 190 |
+
resolveProxyUrl(accountId: string): string | null | undefined {
|
| 191 |
+
const assignment = this.getAssignment(accountId);
|
| 192 |
+
|
| 193 |
+
if (assignment === "global") return undefined;
|
| 194 |
+
if (assignment === "direct") return null;
|
| 195 |
+
|
| 196 |
+
if (assignment === "auto") {
|
| 197 |
+
return this.pickRoundRobin();
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// Specific proxy ID
|
| 201 |
+
const proxy = this.proxies.get(assignment);
|
| 202 |
+
if (!proxy || proxy.status !== "active") {
|
| 203 |
+
// Proxy deleted or unreachable/disabled — fall back to global
|
| 204 |
+
return undefined;
|
| 205 |
+
}
|
| 206 |
+
return proxy.url;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/**
|
| 210 |
+
* Round-robin pick from active proxies.
|
| 211 |
+
* Returns undefined (global) if no active proxies exist.
|
| 212 |
+
*/
|
| 213 |
+
private pickRoundRobin(): string | undefined {
|
| 214 |
+
const active = Array.from(this.proxies.values()).filter(
|
| 215 |
+
(p) => p.status === "active",
|
| 216 |
+
);
|
| 217 |
+
if (active.length === 0) return undefined;
|
| 218 |
+
|
| 219 |
+
this._roundRobinIndex = this._roundRobinIndex % active.length;
|
| 220 |
+
const picked = active[this._roundRobinIndex];
|
| 221 |
+
this._roundRobinIndex = (this._roundRobinIndex + 1) % active.length;
|
| 222 |
+
return picked.url;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// ── Health Check ──────────────────────────────────────────────────
|
| 226 |
+
|
| 227 |
+
async healthCheck(id: string): Promise<ProxyHealthInfo> {
|
| 228 |
+
const proxy = this.proxies.get(id);
|
| 229 |
+
if (!proxy) {
|
| 230 |
+
throw new Error(`Proxy ${id} not found`);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const transport = getTransport();
|
| 234 |
+
const start = Date.now();
|
| 235 |
+
|
| 236 |
+
try {
|
| 237 |
+
const result = await transport.get(
|
| 238 |
+
HEALTH_CHECK_URL,
|
| 239 |
+
{ Accept: "application/json" },
|
| 240 |
+
10,
|
| 241 |
+
proxy.url,
|
| 242 |
+
);
|
| 243 |
+
const latencyMs = Date.now() - start;
|
| 244 |
+
|
| 245 |
+
let exitIp: string | null = null;
|
| 246 |
+
try {
|
| 247 |
+
const parsed = JSON.parse(result.body) as { ip?: string };
|
| 248 |
+
exitIp = parsed.ip ?? null;
|
| 249 |
+
} catch {
|
| 250 |
+
// Could not parse IP
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const info: ProxyHealthInfo = {
|
| 254 |
+
exitIp,
|
| 255 |
+
latencyMs,
|
| 256 |
+
lastChecked: new Date().toISOString(),
|
| 257 |
+
error: null,
|
| 258 |
+
};
|
| 259 |
+
|
| 260 |
+
proxy.health = info;
|
| 261 |
+
// Only change status if not manually disabled
|
| 262 |
+
if (proxy.status !== "disabled") {
|
| 263 |
+
proxy.status = "active";
|
| 264 |
+
}
|
| 265 |
+
this.schedulePersist();
|
| 266 |
+
return info;
|
| 267 |
+
} catch (err) {
|
| 268 |
+
const latencyMs = Date.now() - start;
|
| 269 |
+
const error = err instanceof Error ? err.message : String(err);
|
| 270 |
+
|
| 271 |
+
const info: ProxyHealthInfo = {
|
| 272 |
+
exitIp: null,
|
| 273 |
+
latencyMs,
|
| 274 |
+
lastChecked: new Date().toISOString(),
|
| 275 |
+
error,
|
| 276 |
+
};
|
| 277 |
+
|
| 278 |
+
proxy.health = info;
|
| 279 |
+
if (proxy.status !== "disabled") {
|
| 280 |
+
proxy.status = "unreachable";
|
| 281 |
+
}
|
| 282 |
+
this.schedulePersist();
|
| 283 |
+
return info;
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
async healthCheckAll(): Promise<void> {
|
| 288 |
+
const ids = Array.from(this.proxies.keys());
|
| 289 |
+
if (ids.length === 0) return;
|
| 290 |
+
|
| 291 |
+
console.log(`[ProxyPool] Health checking ${ids.length} proxies...`);
|
| 292 |
+
await Promise.allSettled(ids.map((id) => this.healthCheck(id)));
|
| 293 |
+
|
| 294 |
+
const active = Array.from(this.proxies.values()).filter(
|
| 295 |
+
(p) => p.status === "active",
|
| 296 |
+
).length;
|
| 297 |
+
console.log(
|
| 298 |
+
`[ProxyPool] Health check complete: ${active}/${ids.length} active`,
|
| 299 |
+
);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
startHealthCheckTimer(): void {
|
| 303 |
+
this.stopHealthCheckTimer();
|
| 304 |
+
if (this.proxies.size === 0) return;
|
| 305 |
+
|
| 306 |
+
const intervalMs = this.healthIntervalMin * 60 * 1000;
|
| 307 |
+
this.healthTimer = setInterval(() => {
|
| 308 |
+
this.healthCheckAll().catch((err) => {
|
| 309 |
+
const msg = err instanceof Error ? err.message : String(err);
|
| 310 |
+
console.warn(`[ProxyPool] Periodic health check error: ${msg}`);
|
| 311 |
+
});
|
| 312 |
+
}, intervalMs);
|
| 313 |
+
if (this.healthTimer.unref) this.healthTimer.unref();
|
| 314 |
+
|
| 315 |
+
console.log(
|
| 316 |
+
`[ProxyPool] Health check timer started (every ${this.healthIntervalMin}min)`,
|
| 317 |
+
);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
stopHealthCheckTimer(): void {
|
| 321 |
+
if (this.healthTimer) {
|
| 322 |
+
clearInterval(this.healthTimer);
|
| 323 |
+
this.healthTimer = null;
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
getHealthIntervalMinutes(): number {
|
| 328 |
+
return this.healthIntervalMin;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
setHealthIntervalMinutes(minutes: number): void {
|
| 332 |
+
this.healthIntervalMin = Math.max(1, minutes);
|
| 333 |
+
this.schedulePersist();
|
| 334 |
+
// Restart timer with new interval
|
| 335 |
+
if (this.healthTimer) {
|
| 336 |
+
this.startHealthCheckTimer();
|
| 337 |
+
}
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
// ── Persistence ───────────────────────────────────────────────────
|
| 341 |
+
|
| 342 |
+
private schedulePersist(): void {
|
| 343 |
+
if (this.persistTimer) return;
|
| 344 |
+
this.persistTimer = setTimeout(() => {
|
| 345 |
+
this.persistTimer = null;
|
| 346 |
+
this.persistNow();
|
| 347 |
+
}, 1000);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
persistNow(): void {
|
| 351 |
+
if (this.persistTimer) {
|
| 352 |
+
clearTimeout(this.persistTimer);
|
| 353 |
+
this.persistTimer = null;
|
| 354 |
+
}
|
| 355 |
+
try {
|
| 356 |
+
const filePath = getProxiesFile();
|
| 357 |
+
const dir = dirname(filePath);
|
| 358 |
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
| 359 |
+
|
| 360 |
+
const data: ProxiesFile = {
|
| 361 |
+
proxies: Array.from(this.proxies.values()),
|
| 362 |
+
assignments: this.getAllAssignments(),
|
| 363 |
+
healthCheckIntervalMinutes: this.healthIntervalMin,
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
const tmpFile = filePath + ".tmp";
|
| 367 |
+
writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
|
| 368 |
+
renameSync(tmpFile, filePath);
|
| 369 |
+
} catch (err) {
|
| 370 |
+
console.warn(
|
| 371 |
+
"[ProxyPool] Failed to persist:",
|
| 372 |
+
err instanceof Error ? err.message : err,
|
| 373 |
+
);
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
private load(): void {
|
| 378 |
+
try {
|
| 379 |
+
const filePath = getProxiesFile();
|
| 380 |
+
if (!existsSync(filePath)) return;
|
| 381 |
+
|
| 382 |
+
const raw = readFileSync(filePath, "utf-8");
|
| 383 |
+
const data = JSON.parse(raw) as Partial<ProxiesFile>;
|
| 384 |
+
|
| 385 |
+
if (Array.isArray(data.proxies)) {
|
| 386 |
+
for (const p of data.proxies) {
|
| 387 |
+
if (p && typeof p.id === "string" && typeof p.url === "string") {
|
| 388 |
+
this.proxies.set(p.id, {
|
| 389 |
+
id: p.id,
|
| 390 |
+
name: p.name ?? "",
|
| 391 |
+
url: p.url,
|
| 392 |
+
status: p.status ?? "active",
|
| 393 |
+
health: p.health ?? null,
|
| 394 |
+
addedAt: p.addedAt ?? new Date().toISOString(),
|
| 395 |
+
});
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
if (Array.isArray(data.assignments)) {
|
| 401 |
+
for (const a of data.assignments) {
|
| 402 |
+
if (
|
| 403 |
+
a &&
|
| 404 |
+
typeof a.accountId === "string" &&
|
| 405 |
+
typeof a.proxyId === "string"
|
| 406 |
+
) {
|
| 407 |
+
this.assignments.set(a.accountId, a.proxyId);
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
if (typeof data.healthCheckIntervalMinutes === "number") {
|
| 413 |
+
this.healthIntervalMin = Math.max(1, data.healthCheckIntervalMinutes);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
if (this.proxies.size > 0) {
|
| 417 |
+
console.log(
|
| 418 |
+
`[ProxyPool] Loaded ${this.proxies.size} proxies, ${this.assignments.size} assignments`,
|
| 419 |
+
);
|
| 420 |
+
}
|
| 421 |
+
} catch (err) {
|
| 422 |
+
console.warn(
|
| 423 |
+
"[ProxyPool] Failed to load:",
|
| 424 |
+
err instanceof Error ? err.message : err,
|
| 425 |
+
);
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
destroy(): void {
|
| 430 |
+
this.stopHealthCheckTimer();
|
| 431 |
+
if (this.persistTimer) {
|
| 432 |
+
clearTimeout(this.persistTimer);
|
| 433 |
+
this.persistTimer = null;
|
| 434 |
+
}
|
| 435 |
+
this.persistNow();
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
// ── Helpers ─────────────────────────────────────────────────────────
|
| 440 |
+
|
| 441 |
+
function randomHex(bytes: number): string {
|
| 442 |
+
const arr = new Uint8Array(bytes);
|
| 443 |
+
crypto.getRandomValues(arr);
|
| 444 |
+
return Array.from(arr)
|
| 445 |
+
.map((b) => b.toString(16).padStart(2, "0"))
|
| 446 |
+
.join("");
|
| 447 |
+
}
|
src/routes/accounts.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { CodexApi } from "../proxy/codex-api.js";
|
|
| 23 |
import type { CodexUsageResponse } from "../proxy/codex-api.js";
|
| 24 |
import type { CodexQuota, AccountInfo } from "../auth/types.js";
|
| 25 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
|
|
|
| 26 |
|
| 27 |
function toQuota(usage: CodexUsageResponse): CodexQuota {
|
| 28 |
return {
|
|
@@ -51,12 +52,14 @@ export function createAccountRoutes(
|
|
| 51 |
pool: AccountPool,
|
| 52 |
scheduler: RefreshScheduler,
|
| 53 |
cookieJar?: CookieJar,
|
|
|
|
| 54 |
): Hono {
|
| 55 |
const app = new Hono();
|
| 56 |
|
| 57 |
-
/** Helper: build a CodexApi with cookie support. */
|
| 58 |
function makeApi(entryId: string, token: string, accountId: string | null): CodexApi {
|
| 59 |
-
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
// Start OAuth flow to add a new account — 302 redirect to Auth0
|
|
@@ -73,7 +76,12 @@ export function createAccountRoutes(
|
|
| 73 |
const wantQuota = c.req.query("quota") === "true";
|
| 74 |
|
| 75 |
if (!wantQuota) {
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
// Fetch quota for every active account in parallel
|
|
@@ -93,9 +101,18 @@ export function createAccountRoutes(
|
|
| 93 |
pool.syncRateLimitWindow(acct.id, resetAt, windowSec);
|
| 94 |
// Re-read usage after potential reset
|
| 95 |
const freshAcct = pool.getAccounts().find((a) => a.id === acct.id) ?? acct;
|
| 96 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
} catch {
|
| 98 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
}
|
| 100 |
}),
|
| 101 |
);
|
|
|
|
| 23 |
import type { CodexUsageResponse } from "../proxy/codex-api.js";
|
| 24 |
import type { CodexQuota, AccountInfo } from "../auth/types.js";
|
| 25 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
| 26 |
+
import type { ProxyPool } from "../proxy/proxy-pool.js";
|
| 27 |
|
| 28 |
function toQuota(usage: CodexUsageResponse): CodexQuota {
|
| 29 |
return {
|
|
|
|
| 52 |
pool: AccountPool,
|
| 53 |
scheduler: RefreshScheduler,
|
| 54 |
cookieJar?: CookieJar,
|
| 55 |
+
proxyPool?: ProxyPool,
|
| 56 |
): Hono {
|
| 57 |
const app = new Hono();
|
| 58 |
|
| 59 |
+
/** Helper: build a CodexApi with cookie + proxy support. */
|
| 60 |
function makeApi(entryId: string, token: string, accountId: string | null): CodexApi {
|
| 61 |
+
const proxyUrl = proxyPool?.resolveProxyUrl(entryId);
|
| 62 |
+
return new CodexApi(token, accountId, cookieJar, entryId, proxyUrl);
|
| 63 |
}
|
| 64 |
|
| 65 |
// Start OAuth flow to add a new account — 302 redirect to Auth0
|
|
|
|
| 76 |
const wantQuota = c.req.query("quota") === "true";
|
| 77 |
|
| 78 |
if (!wantQuota) {
|
| 79 |
+
const enrichedBasic = accounts.map((acct) => ({
|
| 80 |
+
...acct,
|
| 81 |
+
proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
|
| 82 |
+
proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
|
| 83 |
+
}));
|
| 84 |
+
return c.json({ accounts: enrichedBasic });
|
| 85 |
}
|
| 86 |
|
| 87 |
// Fetch quota for every active account in parallel
|
|
|
|
| 101 |
pool.syncRateLimitWindow(acct.id, resetAt, windowSec);
|
| 102 |
// Re-read usage after potential reset
|
| 103 |
const freshAcct = pool.getAccounts().find((a) => a.id === acct.id) ?? acct;
|
| 104 |
+
return {
|
| 105 |
+
...freshAcct,
|
| 106 |
+
quota: toQuota(usage),
|
| 107 |
+
proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
|
| 108 |
+
proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
|
| 109 |
+
};
|
| 110 |
} catch {
|
| 111 |
+
return {
|
| 112 |
+
...acct,
|
| 113 |
+
proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
|
| 114 |
+
proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
|
| 115 |
+
};
|
| 116 |
}
|
| 117 |
}),
|
| 118 |
);
|
src/routes/chat.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|
| 2 |
import { ChatCompletionRequestSchema } from "../types/openai.js";
|
| 3 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 4 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
|
|
|
| 5 |
import { translateToCodexRequest } from "../translation/openai-to-codex.js";
|
| 6 |
import {
|
| 7 |
streamCodexToOpenAI,
|
|
@@ -52,6 +53,7 @@ function makeOpenAIFormat(wantReasoning: boolean): FormatAdapter {
|
|
| 52 |
export function createChatRoutes(
|
| 53 |
accountPool: AccountPool,
|
| 54 |
cookieJar?: CookieJar,
|
|
|
|
| 55 |
): Hono {
|
| 56 |
const app = new Hono();
|
| 57 |
|
|
@@ -132,6 +134,7 @@ export function createChatRoutes(
|
|
| 132 |
isStreaming: req.stream,
|
| 133 |
},
|
| 134 |
makeOpenAIFormat(wantReasoning),
|
|
|
|
| 135 |
);
|
| 136 |
});
|
| 137 |
|
|
|
|
| 2 |
import { ChatCompletionRequestSchema } from "../types/openai.js";
|
| 3 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 4 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
| 5 |
+
import type { ProxyPool } from "../proxy/proxy-pool.js";
|
| 6 |
import { translateToCodexRequest } from "../translation/openai-to-codex.js";
|
| 7 |
import {
|
| 8 |
streamCodexToOpenAI,
|
|
|
|
| 53 |
export function createChatRoutes(
|
| 54 |
accountPool: AccountPool,
|
| 55 |
cookieJar?: CookieJar,
|
| 56 |
+
proxyPool?: ProxyPool,
|
| 57 |
): Hono {
|
| 58 |
const app = new Hono();
|
| 59 |
|
|
|
|
| 134 |
isStreaming: req.stream,
|
| 135 |
},
|
| 136 |
makeOpenAIFormat(wantReasoning),
|
| 137 |
+
proxyPool,
|
| 138 |
);
|
| 139 |
});
|
| 140 |
|
src/routes/gemini.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { GEMINI_STATUS_MAP } from "../types/gemini.js";
|
|
| 11 |
import { GeminiGenerateContentRequestSchema } from "../types/gemini.js";
|
| 12 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 13 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
|
|
|
| 14 |
import {
|
| 15 |
translateGeminiToCodexRequest,
|
| 16 |
} from "../translation/gemini-to-codex.js";
|
|
@@ -73,6 +74,7 @@ const GEMINI_FORMAT: FormatAdapter = {
|
|
| 73 |
export function createGeminiRoutes(
|
| 74 |
accountPool: AccountPool,
|
| 75 |
cookieJar?: CookieJar,
|
|
|
|
| 76 |
): Hono {
|
| 77 |
const app = new Hono();
|
| 78 |
|
|
@@ -159,6 +161,7 @@ export function createGeminiRoutes(
|
|
| 159 |
isStreaming,
|
| 160 |
},
|
| 161 |
GEMINI_FORMAT,
|
|
|
|
| 162 |
);
|
| 163 |
});
|
| 164 |
|
|
|
|
| 11 |
import { GeminiGenerateContentRequestSchema } from "../types/gemini.js";
|
| 12 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 13 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
| 14 |
+
import type { ProxyPool } from "../proxy/proxy-pool.js";
|
| 15 |
import {
|
| 16 |
translateGeminiToCodexRequest,
|
| 17 |
} from "../translation/gemini-to-codex.js";
|
|
|
|
| 74 |
export function createGeminiRoutes(
|
| 75 |
accountPool: AccountPool,
|
| 76 |
cookieJar?: CookieJar,
|
| 77 |
+
proxyPool?: ProxyPool,
|
| 78 |
): Hono {
|
| 79 |
const app = new Hono();
|
| 80 |
|
|
|
|
| 161 |
isStreaming,
|
| 162 |
},
|
| 163 |
GEMINI_FORMAT,
|
| 164 |
+
proxyPool,
|
| 165 |
);
|
| 166 |
});
|
| 167 |
|
src/routes/messages.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { AnthropicMessagesRequestSchema } from "../types/anthropic.js";
|
|
| 9 |
import type { AnthropicErrorBody, AnthropicErrorType } from "../types/anthropic.js";
|
| 10 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 11 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
|
|
|
| 12 |
import { translateAnthropicToCodexRequest } from "../translation/anthropic-to-codex.js";
|
| 13 |
import {
|
| 14 |
streamCodexToAnthropic,
|
|
@@ -48,6 +49,7 @@ function makeAnthropicFormat(wantThinking: boolean): FormatAdapter {
|
|
| 48 |
export function createMessagesRoutes(
|
| 49 |
accountPool: AccountPool,
|
| 50 |
cookieJar?: CookieJar,
|
|
|
|
| 51 |
): Hono {
|
| 52 |
const app = new Hono();
|
| 53 |
|
|
@@ -106,6 +108,7 @@ export function createMessagesRoutes(
|
|
| 106 |
isStreaming: req.stream,
|
| 107 |
},
|
| 108 |
makeAnthropicFormat(wantThinking),
|
|
|
|
| 109 |
);
|
| 110 |
});
|
| 111 |
|
|
|
|
| 9 |
import type { AnthropicErrorBody, AnthropicErrorType } from "../types/anthropic.js";
|
| 10 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 11 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
| 12 |
+
import type { ProxyPool } from "../proxy/proxy-pool.js";
|
| 13 |
import { translateAnthropicToCodexRequest } from "../translation/anthropic-to-codex.js";
|
| 14 |
import {
|
| 15 |
streamCodexToAnthropic,
|
|
|
|
| 49 |
export function createMessagesRoutes(
|
| 50 |
accountPool: AccountPool,
|
| 51 |
cookieJar?: CookieJar,
|
| 52 |
+
proxyPool?: ProxyPool,
|
| 53 |
): Hono {
|
| 54 |
const app = new Hono();
|
| 55 |
|
|
|
|
| 108 |
isStreaming: req.stream,
|
| 109 |
},
|
| 110 |
makeAnthropicFormat(wantThinking),
|
| 111 |
+
proxyPool,
|
| 112 |
);
|
| 113 |
});
|
| 114 |
|
src/routes/proxies.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Proxy pool management API routes.
|
| 3 |
+
*
|
| 4 |
+
* GET /api/proxies — list all proxies + assignments
|
| 5 |
+
* POST /api/proxies — add proxy { name, url }
|
| 6 |
+
* PUT /api/proxies/:id — update proxy { name?, url? }
|
| 7 |
+
* DELETE /api/proxies/:id — remove proxy
|
| 8 |
+
* POST /api/proxies/:id/check — health check single proxy
|
| 9 |
+
* POST /api/proxies/:id/enable — enable proxy
|
| 10 |
+
* POST /api/proxies/:id/disable — disable proxy
|
| 11 |
+
* POST /api/proxies/check-all — health check all proxies
|
| 12 |
+
* POST /api/proxies/assign — assign proxy to account { accountId, proxyId }
|
| 13 |
+
* DELETE /api/proxies/assign/:accountId — unassign proxy from account
|
| 14 |
+
* PUT /api/proxies/settings — update settings { healthCheckIntervalMinutes }
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
import { Hono } from "hono";
|
| 18 |
+
import type { ProxyPool } from "../proxy/proxy-pool.js";
|
| 19 |
+
|
| 20 |
+
export function createProxyRoutes(proxyPool: ProxyPool): Hono {
|
| 21 |
+
const app = new Hono();
|
| 22 |
+
|
| 23 |
+
// List all proxies + assignments
|
| 24 |
+
app.get("/api/proxies", (c) => {
|
| 25 |
+
return c.json({
|
| 26 |
+
proxies: proxyPool.getAll(),
|
| 27 |
+
assignments: proxyPool.getAllAssignments(),
|
| 28 |
+
healthCheckIntervalMinutes: proxyPool.getHealthIntervalMinutes(),
|
| 29 |
+
});
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
// Add proxy
|
| 33 |
+
app.post("/api/proxies", async (c) => {
|
| 34 |
+
const body = await c.req.json<{ name?: string; url?: string }>();
|
| 35 |
+
const url = body.url?.trim();
|
| 36 |
+
|
| 37 |
+
if (!url) {
|
| 38 |
+
c.status(400);
|
| 39 |
+
return c.json({ error: "url is required" });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Basic URL validation
|
| 43 |
+
try {
|
| 44 |
+
new URL(url);
|
| 45 |
+
} catch {
|
| 46 |
+
c.status(400);
|
| 47 |
+
return c.json({ error: "Invalid proxy URL format" });
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const name = body.name?.trim() || url;
|
| 51 |
+
const id = proxyPool.add(name, url);
|
| 52 |
+
const proxy = proxyPool.getById(id);
|
| 53 |
+
|
| 54 |
+
// Restart health check timer if this is the first proxy
|
| 55 |
+
proxyPool.startHealthCheckTimer();
|
| 56 |
+
|
| 57 |
+
return c.json({ success: true, proxy });
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
// Update proxy
|
| 61 |
+
app.put("/api/proxies/:id", async (c) => {
|
| 62 |
+
const id = c.req.param("id");
|
| 63 |
+
const body = await c.req.json<{ name?: string; url?: string }>();
|
| 64 |
+
|
| 65 |
+
if (!proxyPool.update(id, body)) {
|
| 66 |
+
c.status(404);
|
| 67 |
+
return c.json({ error: "Proxy not found" });
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
return c.json({ success: true, proxy: proxyPool.getById(id) });
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
// Remove proxy
|
| 74 |
+
app.delete("/api/proxies/:id", (c) => {
|
| 75 |
+
const id = c.req.param("id");
|
| 76 |
+
if (!proxyPool.remove(id)) {
|
| 77 |
+
c.status(404);
|
| 78 |
+
return c.json({ error: "Proxy not found" });
|
| 79 |
+
}
|
| 80 |
+
return c.json({ success: true });
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
// Health check single proxy
|
| 84 |
+
app.post("/api/proxies/:id/check", async (c) => {
|
| 85 |
+
const id = c.req.param("id");
|
| 86 |
+
try {
|
| 87 |
+
const health = await proxyPool.healthCheck(id);
|
| 88 |
+
const proxy = proxyPool.getById(id);
|
| 89 |
+
return c.json({ success: true, proxy, health });
|
| 90 |
+
} catch (err) {
|
| 91 |
+
const msg = err instanceof Error ? err.message : String(err);
|
| 92 |
+
c.status(404);
|
| 93 |
+
return c.json({ error: msg });
|
| 94 |
+
}
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
// Enable proxy
|
| 98 |
+
app.post("/api/proxies/:id/enable", (c) => {
|
| 99 |
+
const id = c.req.param("id");
|
| 100 |
+
if (!proxyPool.enable(id)) {
|
| 101 |
+
c.status(404);
|
| 102 |
+
return c.json({ error: "Proxy not found" });
|
| 103 |
+
}
|
| 104 |
+
return c.json({ success: true, proxy: proxyPool.getById(id) });
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
// Disable proxy
|
| 108 |
+
app.post("/api/proxies/:id/disable", (c) => {
|
| 109 |
+
const id = c.req.param("id");
|
| 110 |
+
if (!proxyPool.disable(id)) {
|
| 111 |
+
c.status(404);
|
| 112 |
+
return c.json({ error: "Proxy not found" });
|
| 113 |
+
}
|
| 114 |
+
return c.json({ success: true, proxy: proxyPool.getById(id) });
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
// Health check all — must be before /:id routes
|
| 118 |
+
app.post("/api/proxies/check-all", async (c) => {
|
| 119 |
+
await proxyPool.healthCheckAll();
|
| 120 |
+
return c.json({
|
| 121 |
+
success: true,
|
| 122 |
+
proxies: proxyPool.getAll(),
|
| 123 |
+
});
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
// Assign proxy to account
|
| 127 |
+
app.post("/api/proxies/assign", async (c) => {
|
| 128 |
+
const body = await c.req.json<{ accountId?: string; proxyId?: string }>();
|
| 129 |
+
const { accountId, proxyId } = body;
|
| 130 |
+
|
| 131 |
+
if (!accountId || !proxyId) {
|
| 132 |
+
c.status(400);
|
| 133 |
+
return c.json({ error: "accountId and proxyId are required" });
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// Validate proxyId is a known value
|
| 137 |
+
const validSpecial = ["global", "direct", "auto"];
|
| 138 |
+
if (!validSpecial.includes(proxyId) && !proxyPool.getById(proxyId)) {
|
| 139 |
+
c.status(400);
|
| 140 |
+
return c.json({ error: "Invalid proxyId. Use 'global', 'direct', 'auto', or a valid proxy ID." });
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
proxyPool.assign(accountId, proxyId);
|
| 144 |
+
return c.json({
|
| 145 |
+
success: true,
|
| 146 |
+
assignment: { accountId, proxyId },
|
| 147 |
+
displayName: proxyPool.getAssignmentDisplayName(accountId),
|
| 148 |
+
});
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
// Unassign proxy from account
|
| 152 |
+
app.delete("/api/proxies/assign/:accountId", (c) => {
|
| 153 |
+
const accountId = c.req.param("accountId");
|
| 154 |
+
proxyPool.unassign(accountId);
|
| 155 |
+
return c.json({ success: true });
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
// Update settings
|
| 159 |
+
app.put("/api/proxies/settings", async (c) => {
|
| 160 |
+
const body = await c.req.json<{ healthCheckIntervalMinutes?: number }>();
|
| 161 |
+
if (typeof body.healthCheckIntervalMinutes === "number") {
|
| 162 |
+
proxyPool.setHealthIntervalMinutes(body.healthCheckIntervalMinutes);
|
| 163 |
+
}
|
| 164 |
+
return c.json({
|
| 165 |
+
success: true,
|
| 166 |
+
healthCheckIntervalMinutes: proxyPool.getHealthIntervalMinutes(),
|
| 167 |
+
});
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
return app;
|
| 171 |
+
}
|
src/routes/shared/proxy-handler.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { CodexResponsesRequest } from "../../proxy/codex-api.js";
|
|
| 14 |
import { EmptyResponseError } from "../../translation/codex-event-extractor.js";
|
| 15 |
import type { AccountPool } from "../../auth/account-pool.js";
|
| 16 |
import type { CookieJar } from "../../proxy/cookie-jar.js";
|
|
|
|
| 17 |
import { withRetry } from "../../utils/retry.js";
|
| 18 |
|
| 19 |
/** Data prepared by each route after parsing and translating the request. */
|
|
@@ -59,6 +60,7 @@ export async function handleProxyRequest(
|
|
| 59 |
cookieJar: CookieJar | undefined,
|
| 60 |
req: ProxyRequest,
|
| 61 |
fmt: FormatAdapter,
|
|
|
|
| 62 |
): Promise<Response> {
|
| 63 |
// 1. Acquire account
|
| 64 |
const acquired = accountPool.acquire();
|
|
@@ -68,7 +70,8 @@ export async function handleProxyRequest(
|
|
| 68 |
}
|
| 69 |
|
| 70 |
const { entryId, token, accountId } = acquired;
|
| 71 |
-
const
|
|
|
|
| 72 |
// Tracks which account the outer catch should release (updated by retry loop)
|
| 73 |
let activeEntryId = entryId;
|
| 74 |
|
|
@@ -158,7 +161,8 @@ export async function handleProxyRequest(
|
|
| 158 |
|
| 159 |
currentEntryId = newAcquired.entryId;
|
| 160 |
activeEntryId = currentEntryId;
|
| 161 |
-
|
|
|
|
| 162 |
try {
|
| 163 |
currentRawResponse = await withRetry(
|
| 164 |
() => currentCodexApi.createResponse(req.codexRequest, abortController.signal),
|
|
|
|
| 14 |
import { EmptyResponseError } from "../../translation/codex-event-extractor.js";
|
| 15 |
import type { AccountPool } from "../../auth/account-pool.js";
|
| 16 |
import type { CookieJar } from "../../proxy/cookie-jar.js";
|
| 17 |
+
import type { ProxyPool } from "../../proxy/proxy-pool.js";
|
| 18 |
import { withRetry } from "../../utils/retry.js";
|
| 19 |
|
| 20 |
/** Data prepared by each route after parsing and translating the request. */
|
|
|
|
| 60 |
cookieJar: CookieJar | undefined,
|
| 61 |
req: ProxyRequest,
|
| 62 |
fmt: FormatAdapter,
|
| 63 |
+
proxyPool?: ProxyPool,
|
| 64 |
): Promise<Response> {
|
| 65 |
// 1. Acquire account
|
| 66 |
const acquired = accountPool.acquire();
|
|
|
|
| 70 |
}
|
| 71 |
|
| 72 |
const { entryId, token, accountId } = acquired;
|
| 73 |
+
const proxyUrl = proxyPool?.resolveProxyUrl(entryId);
|
| 74 |
+
const codexApi = new CodexApi(token, accountId, cookieJar, entryId, proxyUrl);
|
| 75 |
// Tracks which account the outer catch should release (updated by retry loop)
|
| 76 |
let activeEntryId = entryId;
|
| 77 |
|
|
|
|
| 161 |
|
| 162 |
currentEntryId = newAcquired.entryId;
|
| 163 |
activeEntryId = currentEntryId;
|
| 164 |
+
const retryProxyUrl = proxyPool?.resolveProxyUrl(newAcquired.entryId);
|
| 165 |
+
currentCodexApi = new CodexApi(newAcquired.token, newAcquired.accountId, cookieJar, newAcquired.entryId, retryProxyUrl);
|
| 166 |
try {
|
| 167 |
currentRawResponse = await withRetry(
|
| 168 |
() => currentCodexApi.createResponse(req.codexRequest, abortController.signal),
|
src/tls/curl-cli-transport.ts
CHANGED
|
@@ -25,11 +25,12 @@ export class CurlCliTransport implements TlsTransport {
|
|
| 25 |
body: string,
|
| 26 |
signal?: AbortSignal,
|
| 27 |
timeoutSec?: number,
|
|
|
|
| 28 |
): Promise<TlsTransportResponse> {
|
| 29 |
return new Promise((resolve, reject) => {
|
| 30 |
const args = [
|
| 31 |
...getChromeTlsArgs(),
|
| 32 |
-
...
|
| 33 |
"-s", "-S",
|
| 34 |
"--compressed",
|
| 35 |
"-N", // no output buffering (SSE)
|
|
@@ -159,10 +160,11 @@ export class CurlCliTransport implements TlsTransport {
|
|
| 159 |
url: string,
|
| 160 |
headers: Record<string, string>,
|
| 161 |
timeoutSec = 30,
|
|
|
|
| 162 |
): Promise<{ status: number; body: string }> {
|
| 163 |
const args = [
|
| 164 |
...getChromeTlsArgs(),
|
| 165 |
-
...
|
| 166 |
"-s", "-S",
|
| 167 |
"--compressed",
|
| 168 |
"--max-time", String(timeoutSec),
|
|
@@ -187,10 +189,11 @@ export class CurlCliTransport implements TlsTransport {
|
|
| 187 |
headers: Record<string, string>,
|
| 188 |
body: string,
|
| 189 |
timeoutSec = 30,
|
|
|
|
| 190 |
): Promise<{ status: number; body: string }> {
|
| 191 |
const args = [
|
| 192 |
...getChromeTlsArgs(),
|
| 193 |
-
...
|
| 194 |
"-s", "-S",
|
| 195 |
"--compressed",
|
| 196 |
"--max-time", String(timeoutSec),
|
|
@@ -241,6 +244,16 @@ function execCurl(args: string[]): Promise<{ status: number; body: string }> {
|
|
| 241 |
});
|
| 242 |
}
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
/** Parse HTTP response header block from curl -i output. */
|
| 245 |
function parseHeaderDump(headerBlock: string): {
|
| 246 |
status: number;
|
|
|
|
| 25 |
body: string,
|
| 26 |
signal?: AbortSignal,
|
| 27 |
timeoutSec?: number,
|
| 28 |
+
proxyUrl?: string | null,
|
| 29 |
): Promise<TlsTransportResponse> {
|
| 30 |
return new Promise((resolve, reject) => {
|
| 31 |
const args = [
|
| 32 |
...getChromeTlsArgs(),
|
| 33 |
+
...resolveProxyArgs(proxyUrl),
|
| 34 |
"-s", "-S",
|
| 35 |
"--compressed",
|
| 36 |
"-N", // no output buffering (SSE)
|
|
|
|
| 160 |
url: string,
|
| 161 |
headers: Record<string, string>,
|
| 162 |
timeoutSec = 30,
|
| 163 |
+
proxyUrl?: string | null,
|
| 164 |
): Promise<{ status: number; body: string }> {
|
| 165 |
const args = [
|
| 166 |
...getChromeTlsArgs(),
|
| 167 |
+
...resolveProxyArgs(proxyUrl),
|
| 168 |
"-s", "-S",
|
| 169 |
"--compressed",
|
| 170 |
"--max-time", String(timeoutSec),
|
|
|
|
| 189 |
headers: Record<string, string>,
|
| 190 |
body: string,
|
| 191 |
timeoutSec = 30,
|
| 192 |
+
proxyUrl?: string | null,
|
| 193 |
): Promise<{ status: number; body: string }> {
|
| 194 |
const args = [
|
| 195 |
...getChromeTlsArgs(),
|
| 196 |
+
...resolveProxyArgs(proxyUrl),
|
| 197 |
"-s", "-S",
|
| 198 |
"--compressed",
|
| 199 |
"--max-time", String(timeoutSec),
|
|
|
|
| 244 |
});
|
| 245 |
}
|
| 246 |
|
| 247 |
+
/**
|
| 248 |
+
* Resolve proxy args for curl CLI.
|
| 249 |
+
* undefined → global default | null → no proxy | string → specific proxy
|
| 250 |
+
*/
|
| 251 |
+
function resolveProxyArgs(proxyUrl: string | null | undefined): string[] {
|
| 252 |
+
if (proxyUrl === null) return [];
|
| 253 |
+
if (proxyUrl !== undefined) return ["-x", proxyUrl];
|
| 254 |
+
return getProxyArgs();
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
/** Parse HTTP response header block from curl -i output. */
|
| 258 |
function parseHeaderDump(headerBlock: string): {
|
| 259 |
status: number;
|
src/tls/libcurl-ffi-transport.ts
CHANGED
|
@@ -215,6 +215,7 @@ export class LibcurlFfiTransport implements TlsTransport {
|
|
| 215 |
body: string,
|
| 216 |
signal?: AbortSignal,
|
| 217 |
timeoutSec?: number,
|
|
|
|
| 218 |
): Promise<TlsTransportResponse> {
|
| 219 |
return new Promise((resolve, reject) => {
|
| 220 |
if (signal?.aborted) {
|
|
@@ -226,6 +227,7 @@ export class LibcurlFfiTransport implements TlsTransport {
|
|
| 226 |
method: "POST",
|
| 227 |
body,
|
| 228 |
timeoutSec,
|
|
|
|
| 229 |
});
|
| 230 |
|
| 231 |
const b = this.b;
|
|
@@ -372,8 +374,9 @@ export class LibcurlFfiTransport implements TlsTransport {
|
|
| 372 |
url: string,
|
| 373 |
headers: Record<string, string>,
|
| 374 |
timeoutSec = 30,
|
|
|
|
| 375 |
): Promise<{ status: number; body: string }> {
|
| 376 |
-
return this.simpleRequest(url, headers, undefined, timeoutSec);
|
| 377 |
}
|
| 378 |
|
| 379 |
async simplePost(
|
|
@@ -381,8 +384,9 @@ export class LibcurlFfiTransport implements TlsTransport {
|
|
| 381 |
headers: Record<string, string>,
|
| 382 |
body: string,
|
| 383 |
timeoutSec = 30,
|
|
|
|
| 384 |
): Promise<{ status: number; body: string }> {
|
| 385 |
-
return this.simpleRequest(url, headers, body, timeoutSec);
|
| 386 |
}
|
| 387 |
|
| 388 |
isImpersonate(): boolean {
|
|
@@ -394,12 +398,14 @@ export class LibcurlFfiTransport implements TlsTransport {
|
|
| 394 |
headers: Record<string, string>,
|
| 395 |
body: string | undefined,
|
| 396 |
timeoutSec: number,
|
|
|
|
| 397 |
): Promise<{ status: number; body: string }> {
|
| 398 |
const b = this.b;
|
| 399 |
const { easy, slist } = this.setupEasyHandle(url, headers, {
|
| 400 |
method: body !== undefined ? "POST" : "GET",
|
| 401 |
body,
|
| 402 |
timeoutSec,
|
|
|
|
| 403 |
});
|
| 404 |
|
| 405 |
const chunks: Buffer[] = [];
|
|
@@ -444,6 +450,7 @@ export class LibcurlFfiTransport implements TlsTransport {
|
|
| 444 |
method?: "GET" | "POST";
|
| 445 |
body?: string;
|
| 446 |
timeoutSec?: number;
|
|
|
|
| 447 |
} = {},
|
| 448 |
): { easy: CurlHandle; slist: SlistHandle } {
|
| 449 |
const b = this.b;
|
|
@@ -469,10 +476,11 @@ export class LibcurlFfiTransport implements TlsTransport {
|
|
| 469 |
b.curl_easy_setopt_long(easy, CURLOPT_TIMEOUT, opts.timeoutSec);
|
| 470 |
}
|
| 471 |
|
| 472 |
-
// Proxy
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
|
|
|
| 476 |
}
|
| 477 |
|
| 478 |
// Headers — build slist
|
|
|
|
| 215 |
body: string,
|
| 216 |
signal?: AbortSignal,
|
| 217 |
timeoutSec?: number,
|
| 218 |
+
proxyUrl?: string | null,
|
| 219 |
): Promise<TlsTransportResponse> {
|
| 220 |
return new Promise((resolve, reject) => {
|
| 221 |
if (signal?.aborted) {
|
|
|
|
| 227 |
method: "POST",
|
| 228 |
body,
|
| 229 |
timeoutSec,
|
| 230 |
+
proxyUrl,
|
| 231 |
});
|
| 232 |
|
| 233 |
const b = this.b;
|
|
|
|
| 374 |
url: string,
|
| 375 |
headers: Record<string, string>,
|
| 376 |
timeoutSec = 30,
|
| 377 |
+
proxyUrl?: string | null,
|
| 378 |
): Promise<{ status: number; body: string }> {
|
| 379 |
+
return this.simpleRequest(url, headers, undefined, timeoutSec, proxyUrl);
|
| 380 |
}
|
| 381 |
|
| 382 |
async simplePost(
|
|
|
|
| 384 |
headers: Record<string, string>,
|
| 385 |
body: string,
|
| 386 |
timeoutSec = 30,
|
| 387 |
+
proxyUrl?: string | null,
|
| 388 |
): Promise<{ status: number; body: string }> {
|
| 389 |
+
return this.simpleRequest(url, headers, body, timeoutSec, proxyUrl);
|
| 390 |
}
|
| 391 |
|
| 392 |
isImpersonate(): boolean {
|
|
|
|
| 398 |
headers: Record<string, string>,
|
| 399 |
body: string | undefined,
|
| 400 |
timeoutSec: number,
|
| 401 |
+
proxyUrl?: string | null,
|
| 402 |
): Promise<{ status: number; body: string }> {
|
| 403 |
const b = this.b;
|
| 404 |
const { easy, slist } = this.setupEasyHandle(url, headers, {
|
| 405 |
method: body !== undefined ? "POST" : "GET",
|
| 406 |
body,
|
| 407 |
timeoutSec,
|
| 408 |
+
proxyUrl,
|
| 409 |
});
|
| 410 |
|
| 411 |
const chunks: Buffer[] = [];
|
|
|
|
| 450 |
method?: "GET" | "POST";
|
| 451 |
body?: string;
|
| 452 |
timeoutSec?: number;
|
| 453 |
+
proxyUrl?: string | null;
|
| 454 |
} = {},
|
| 455 |
): { easy: CurlHandle; slist: SlistHandle } {
|
| 456 |
const b = this.b;
|
|
|
|
| 476 |
b.curl_easy_setopt_long(easy, CURLOPT_TIMEOUT, opts.timeoutSec);
|
| 477 |
}
|
| 478 |
|
| 479 |
+
// Proxy: per-request override > global default
|
| 480 |
+
// null = direct (no proxy), undefined = use global, string = specific proxy
|
| 481 |
+
const effectiveProxy = opts.proxyUrl === null ? null : (opts.proxyUrl ?? getProxyUrl());
|
| 482 |
+
if (effectiveProxy) {
|
| 483 |
+
b.curl_easy_setopt_str(easy, CURLOPT_PROXY, effectiveProxy);
|
| 484 |
}
|
| 485 |
|
| 486 |
// Headers — build slist
|
src/tls/transport.ts
CHANGED
|
@@ -17,28 +17,40 @@ export interface TlsTransportResponse {
|
|
| 17 |
}
|
| 18 |
|
| 19 |
export interface TlsTransport {
|
| 20 |
-
/**
|
|
|
|
|
|
|
|
|
|
| 21 |
post(
|
| 22 |
url: string,
|
| 23 |
headers: Record<string, string>,
|
| 24 |
body: string,
|
| 25 |
signal?: AbortSignal,
|
| 26 |
timeoutSec?: number,
|
|
|
|
| 27 |
): Promise<TlsTransportResponse>;
|
| 28 |
|
| 29 |
-
/**
|
|
|
|
|
|
|
|
|
|
| 30 |
get(
|
| 31 |
url: string,
|
| 32 |
headers: Record<string, string>,
|
| 33 |
timeoutSec?: number,
|
|
|
|
| 34 |
): Promise<{ status: number; body: string }>;
|
| 35 |
|
| 36 |
-
/**
|
|
|
|
|
|
|
|
|
|
| 37 |
simplePost(
|
| 38 |
url: string,
|
| 39 |
headers: Record<string, string>,
|
| 40 |
body: string,
|
| 41 |
timeoutSec?: number,
|
|
|
|
| 42 |
): Promise<{ status: number; body: string }>;
|
| 43 |
|
| 44 |
/** Whether this transport provides a Chrome TLS fingerprint. */
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
export interface TlsTransport {
|
| 20 |
+
/**
|
| 21 |
+
* Streaming POST (for SSE). Returns headers + streaming body.
|
| 22 |
+
* @param proxyUrl undefined = global default, null = direct (no proxy), string = specific proxy
|
| 23 |
+
*/
|
| 24 |
post(
|
| 25 |
url: string,
|
| 26 |
headers: Record<string, string>,
|
| 27 |
body: string,
|
| 28 |
signal?: AbortSignal,
|
| 29 |
timeoutSec?: number,
|
| 30 |
+
proxyUrl?: string | null,
|
| 31 |
): Promise<TlsTransportResponse>;
|
| 32 |
|
| 33 |
+
/**
|
| 34 |
+
* Simple GET — returns full body as string.
|
| 35 |
+
* @param proxyUrl undefined = global default, null = direct (no proxy), string = specific proxy
|
| 36 |
+
*/
|
| 37 |
get(
|
| 38 |
url: string,
|
| 39 |
headers: Record<string, string>,
|
| 40 |
timeoutSec?: number,
|
| 41 |
+
proxyUrl?: string | null,
|
| 42 |
): Promise<{ status: number; body: string }>;
|
| 43 |
|
| 44 |
+
/**
|
| 45 |
+
* Simple (non-streaming) POST — returns full body as string.
|
| 46 |
+
* @param proxyUrl undefined = global default, null = direct (no proxy), string = specific proxy
|
| 47 |
+
*/
|
| 48 |
simplePost(
|
| 49 |
url: string,
|
| 50 |
headers: Record<string, string>,
|
| 51 |
body: string,
|
| 52 |
timeoutSec?: number,
|
| 53 |
+
proxyUrl?: string | null,
|
| 54 |
): Promise<{ status: number; body: string }>;
|
| 55 |
|
| 56 |
/** Whether this transport provides a Chrome TLS fingerprint. */
|
web/src/App.tsx
CHANGED
|
@@ -3,11 +3,13 @@ import { ThemeProvider } from "../../shared/theme/context";
|
|
| 3 |
import { Header } from "./components/Header";
|
| 4 |
import { AccountList } from "./components/AccountList";
|
| 5 |
import { AddAccount } from "./components/AddAccount";
|
|
|
|
| 6 |
import { ApiConfig } from "./components/ApiConfig";
|
| 7 |
import { AnthropicSetup } from "./components/AnthropicSetup";
|
| 8 |
import { CodeExamples } from "./components/CodeExamples";
|
| 9 |
import { Footer } from "./components/Footer";
|
| 10 |
import { useAccounts } from "../../shared/hooks/use-accounts";
|
|
|
|
| 11 |
import { useStatus } from "../../shared/hooks/use-status";
|
| 12 |
import { useUpdateStatus } from "../../shared/hooks/use-update-status";
|
| 13 |
import { useI18n } from "../../shared/i18n/context";
|
|
@@ -52,9 +54,15 @@ function useUpdateMessage() {
|
|
| 52 |
|
| 53 |
function Dashboard() {
|
| 54 |
const accounts = useAccounts();
|
|
|
|
| 55 |
const status = useStatus(accounts.list.length);
|
| 56 |
const update = useUpdateMessage();
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
return (
|
| 59 |
<>
|
| 60 |
<Header
|
|
@@ -79,7 +87,10 @@ function Dashboard() {
|
|
| 79 |
onRefresh={accounts.refresh}
|
| 80 |
refreshing={accounts.refreshing}
|
| 81 |
lastUpdated={accounts.lastUpdated}
|
|
|
|
|
|
|
| 82 |
/>
|
|
|
|
| 83 |
<ApiConfig
|
| 84 |
baseUrl={status.baseUrl}
|
| 85 |
apiKey={status.apiKey}
|
|
|
|
| 3 |
import { Header } from "./components/Header";
|
| 4 |
import { AccountList } from "./components/AccountList";
|
| 5 |
import { AddAccount } from "./components/AddAccount";
|
| 6 |
+
import { ProxyPool } from "./components/ProxyPool";
|
| 7 |
import { ApiConfig } from "./components/ApiConfig";
|
| 8 |
import { AnthropicSetup } from "./components/AnthropicSetup";
|
| 9 |
import { CodeExamples } from "./components/CodeExamples";
|
| 10 |
import { Footer } from "./components/Footer";
|
| 11 |
import { useAccounts } from "../../shared/hooks/use-accounts";
|
| 12 |
+
import { useProxies } from "../../shared/hooks/use-proxies";
|
| 13 |
import { useStatus } from "../../shared/hooks/use-status";
|
| 14 |
import { useUpdateStatus } from "../../shared/hooks/use-update-status";
|
| 15 |
import { useI18n } from "../../shared/i18n/context";
|
|
|
|
| 54 |
|
| 55 |
function Dashboard() {
|
| 56 |
const accounts = useAccounts();
|
| 57 |
+
const proxies = useProxies();
|
| 58 |
const status = useStatus(accounts.list.length);
|
| 59 |
const update = useUpdateMessage();
|
| 60 |
|
| 61 |
+
const handleProxyChange = async (accountId: string, proxyId: string) => {
|
| 62 |
+
await proxies.assignProxy(accountId, proxyId);
|
| 63 |
+
accounts.refresh();
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
return (
|
| 67 |
<>
|
| 68 |
<Header
|
|
|
|
| 87 |
onRefresh={accounts.refresh}
|
| 88 |
refreshing={accounts.refreshing}
|
| 89 |
lastUpdated={accounts.lastUpdated}
|
| 90 |
+
proxies={proxies.proxies}
|
| 91 |
+
onProxyChange={handleProxyChange}
|
| 92 |
/>
|
| 93 |
+
<ProxyPool proxies={proxies} />
|
| 94 |
<ApiConfig
|
| 95 |
baseUrl={status.baseUrl}
|
| 96 |
apiKey={status.apiKey}
|
web/src/components/AccountCard.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { useCallback } from "preact/hooks";
|
|
| 2 |
import { useT, useI18n } from "../../../shared/i18n/context";
|
| 3 |
import type { TranslationKey } from "../../../shared/i18n/translations";
|
| 4 |
import { formatNumber, formatResetTime, formatWindowDuration } from "../../../shared/utils/format";
|
| 5 |
-
import type { Account } from "../../../shared/types";
|
| 6 |
|
| 7 |
const avatarColors = [
|
| 8 |
["bg-purple-100 dark:bg-[#2a1a3f]", "text-purple-600 dark:text-purple-400"],
|
|
@@ -39,9 +39,11 @@ interface AccountCardProps {
|
|
| 39 |
account: Account;
|
| 40 |
index: number;
|
| 41 |
onDelete: (id: string) => Promise<string | null>;
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
-
export function AccountCard({ account, index, onDelete }: AccountCardProps) {
|
| 45 |
const t = useT();
|
| 46 |
const { lang } = useI18n();
|
| 47 |
const email = account.email || "Unknown";
|
|
@@ -132,6 +134,30 @@ export function AccountCard({ account, index, onDelete }: AccountCardProps) {
|
|
| 132 |
</div>
|
| 133 |
</div>
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
{/* Quota bar */}
|
| 136 |
{rl && (
|
| 137 |
<div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
|
|
|
|
| 2 |
import { useT, useI18n } from "../../../shared/i18n/context";
|
| 3 |
import type { TranslationKey } from "../../../shared/i18n/translations";
|
| 4 |
import { formatNumber, formatResetTime, formatWindowDuration } from "../../../shared/utils/format";
|
| 5 |
+
import type { Account, ProxyEntry } from "../../../shared/types";
|
| 6 |
|
| 7 |
const avatarColors = [
|
| 8 |
["bg-purple-100 dark:bg-[#2a1a3f]", "text-purple-600 dark:text-purple-400"],
|
|
|
|
| 39 |
account: Account;
|
| 40 |
index: number;
|
| 41 |
onDelete: (id: string) => Promise<string | null>;
|
| 42 |
+
proxies?: ProxyEntry[];
|
| 43 |
+
onProxyChange?: (accountId: string, proxyId: string) => void;
|
| 44 |
}
|
| 45 |
|
| 46 |
+
export function AccountCard({ account, index, onDelete, proxies, onProxyChange }: AccountCardProps) {
|
| 47 |
const t = useT();
|
| 48 |
const { lang } = useI18n();
|
| 49 |
const email = account.email || "Unknown";
|
|
|
|
| 134 |
</div>
|
| 135 |
</div>
|
| 136 |
|
| 137 |
+
{/* Proxy selector */}
|
| 138 |
+
{proxies && onProxyChange && (
|
| 139 |
+
<div class="flex items-center justify-between text-[0.78rem] mt-2 pt-2 border-t border-slate-100 dark:border-border-dark">
|
| 140 |
+
<span class="text-slate-500 dark:text-text-dim">{t("proxyAssignment")}</span>
|
| 141 |
+
<select
|
| 142 |
+
value={account.proxyId || "global"}
|
| 143 |
+
onChange={(e) =>
|
| 144 |
+
onProxyChange(account.id, (e.target as HTMLSelectElement).value)
|
| 145 |
+
}
|
| 146 |
+
class="text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-border-dark bg-transparent focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
| 147 |
+
>
|
| 148 |
+
<option value="global">{t("globalDefault")}</option>
|
| 149 |
+
<option value="direct">{t("directNoProxy")}</option>
|
| 150 |
+
<option value="auto">{t("autoRoundRobin")}</option>
|
| 151 |
+
{proxies.map((p) => (
|
| 152 |
+
<option key={p.id} value={p.id}>
|
| 153 |
+
{p.name}
|
| 154 |
+
{p.health?.exitIp ? ` (${p.health.exitIp})` : ""}
|
| 155 |
+
</option>
|
| 156 |
+
))}
|
| 157 |
+
</select>
|
| 158 |
+
</div>
|
| 159 |
+
)}
|
| 160 |
+
|
| 161 |
{/* Quota bar */}
|
| 162 |
{rl && (
|
| 163 |
<div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
|
web/src/components/AccountList.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useI18n, useT } from "../../../shared/i18n/context";
|
| 2 |
import { AccountCard } from "./AccountCard";
|
| 3 |
-
import type { Account } from "../../../shared/types";
|
| 4 |
|
| 5 |
interface AccountListProps {
|
| 6 |
accounts: Account[];
|
|
@@ -9,9 +9,11 @@ interface AccountListProps {
|
|
| 9 |
onRefresh: () => void;
|
| 10 |
refreshing: boolean;
|
| 11 |
lastUpdated: Date | null;
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
-
export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing, lastUpdated }: AccountListProps) {
|
| 15 |
const t = useT();
|
| 16 |
const { lang } = useI18n();
|
| 17 |
|
|
@@ -61,7 +63,7 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
|
|
| 61 |
</div>
|
| 62 |
) : (
|
| 63 |
accounts.map((acct, i) => (
|
| 64 |
-
<AccountCard key={acct.id} account={acct} index={i} onDelete={onDelete} />
|
| 65 |
))
|
| 66 |
)}
|
| 67 |
</div>
|
|
|
|
| 1 |
import { useI18n, useT } from "../../../shared/i18n/context";
|
| 2 |
import { AccountCard } from "./AccountCard";
|
| 3 |
+
import type { Account, ProxyEntry } from "../../../shared/types";
|
| 4 |
|
| 5 |
interface AccountListProps {
|
| 6 |
accounts: Account[];
|
|
|
|
| 9 |
onRefresh: () => void;
|
| 10 |
refreshing: boolean;
|
| 11 |
lastUpdated: Date | null;
|
| 12 |
+
proxies?: ProxyEntry[];
|
| 13 |
+
onProxyChange?: (accountId: string, proxyId: string) => void;
|
| 14 |
}
|
| 15 |
|
| 16 |
+
export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing, lastUpdated, proxies, onProxyChange }: AccountListProps) {
|
| 17 |
const t = useT();
|
| 18 |
const { lang } = useI18n();
|
| 19 |
|
|
|
|
| 63 |
</div>
|
| 64 |
) : (
|
| 65 |
accounts.map((acct, i) => (
|
| 66 |
+
<AccountCard key={acct.id} account={acct} index={i} onDelete={onDelete} proxies={proxies} onProxyChange={onProxyChange} />
|
| 67 |
))
|
| 68 |
)}
|
| 69 |
</div>
|
web/src/components/ProxyPool.tsx
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import { useT } from "../../../shared/i18n/context";
|
| 3 |
+
import type { TranslationKey } from "../../../shared/i18n/translations";
|
| 4 |
+
import type { ProxiesState } from "../../../shared/hooks/use-proxies";
|
| 5 |
+
|
| 6 |
+
const statusStyles: Record<string, [string, TranslationKey]> = {
|
| 7 |
+
active: [
|
| 8 |
+
"bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
|
| 9 |
+
"proxyActive",
|
| 10 |
+
],
|
| 11 |
+
unreachable: [
|
| 12 |
+
"bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30",
|
| 13 |
+
"proxyUnreachable",
|
| 14 |
+
],
|
| 15 |
+
disabled: [
|
| 16 |
+
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
|
| 17 |
+
"proxyDisabled",
|
| 18 |
+
],
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
function maskUrl(url: string): string {
|
| 22 |
+
try {
|
| 23 |
+
const u = new URL(url);
|
| 24 |
+
if (u.password) {
|
| 25 |
+
u.password = "***";
|
| 26 |
+
}
|
| 27 |
+
return u.toString();
|
| 28 |
+
} catch {
|
| 29 |
+
return url;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface ProxyPoolProps {
|
| 34 |
+
proxies: ProxiesState;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function ProxyPool({ proxies }: ProxyPoolProps) {
|
| 38 |
+
const t = useT();
|
| 39 |
+
const [showAdd, setShowAdd] = useState(false);
|
| 40 |
+
const [newName, setNewName] = useState("");
|
| 41 |
+
const [newUrl, setNewUrl] = useState("");
|
| 42 |
+
const [addError, setAddError] = useState("");
|
| 43 |
+
const [checking, setChecking] = useState<string | null>(null);
|
| 44 |
+
const [checkingAll, setCheckingAll] = useState(false);
|
| 45 |
+
|
| 46 |
+
const handleAdd = useCallback(async () => {
|
| 47 |
+
setAddError("");
|
| 48 |
+
if (!newUrl.trim()) {
|
| 49 |
+
setAddError("URL is required");
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
const err = await proxies.addProxy(newName || newUrl, newUrl);
|
| 53 |
+
if (err) {
|
| 54 |
+
setAddError(err);
|
| 55 |
+
} else {
|
| 56 |
+
setNewName("");
|
| 57 |
+
setNewUrl("");
|
| 58 |
+
setShowAdd(false);
|
| 59 |
+
}
|
| 60 |
+
}, [newName, newUrl, proxies]);
|
| 61 |
+
|
| 62 |
+
const handleCheck = useCallback(
|
| 63 |
+
async (id: string) => {
|
| 64 |
+
setChecking(id);
|
| 65 |
+
await proxies.checkProxy(id);
|
| 66 |
+
setChecking(null);
|
| 67 |
+
},
|
| 68 |
+
[proxies],
|
| 69 |
+
);
|
| 70 |
+
|
| 71 |
+
const handleCheckAll = useCallback(async () => {
|
| 72 |
+
setCheckingAll(true);
|
| 73 |
+
await proxies.checkAll();
|
| 74 |
+
setCheckingAll(false);
|
| 75 |
+
}, [proxies]);
|
| 76 |
+
|
| 77 |
+
const handleDelete = useCallback(
|
| 78 |
+
async (id: string) => {
|
| 79 |
+
if (!confirm(t("removeProxyConfirm"))) return;
|
| 80 |
+
await proxies.removeProxy(id);
|
| 81 |
+
},
|
| 82 |
+
[proxies, t],
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
return (
|
| 86 |
+
<section>
|
| 87 |
+
{/* Header */}
|
| 88 |
+
<div class="flex items-center justify-between mb-2">
|
| 89 |
+
<div>
|
| 90 |
+
<h2 class="text-lg font-semibold">{t("proxyPool")}</h2>
|
| 91 |
+
<p class="text-xs text-slate-500 dark:text-text-dim mt-0.5">
|
| 92 |
+
{t("proxyPoolDesc")}
|
| 93 |
+
</p>
|
| 94 |
+
</div>
|
| 95 |
+
<div class="flex items-center gap-2">
|
| 96 |
+
{proxies.proxies.length > 0 && (
|
| 97 |
+
<button
|
| 98 |
+
onClick={handleCheckAll}
|
| 99 |
+
disabled={checkingAll}
|
| 100 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors disabled:opacity-50"
|
| 101 |
+
>
|
| 102 |
+
{checkingAll ? "..." : t("checkAllHealth")}
|
| 103 |
+
</button>
|
| 104 |
+
)}
|
| 105 |
+
<button
|
| 106 |
+
onClick={() => setShowAdd(!showAdd)}
|
| 107 |
+
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
|
| 108 |
+
>
|
| 109 |
+
{t("addProxy")}
|
| 110 |
+
</button>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
{/* Add form */}
|
| 115 |
+
{showAdd && (
|
| 116 |
+
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 mb-4">
|
| 117 |
+
<div class="flex flex-col sm:flex-row gap-3">
|
| 118 |
+
<input
|
| 119 |
+
type="text"
|
| 120 |
+
placeholder={t("proxyName")}
|
| 121 |
+
value={newName}
|
| 122 |
+
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
|
| 123 |
+
class="flex-1 px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-transparent focus:outline-none focus:ring-1 focus:ring-primary"
|
| 124 |
+
/>
|
| 125 |
+
<input
|
| 126 |
+
type="text"
|
| 127 |
+
placeholder="http://host:port or socks5://host:port"
|
| 128 |
+
value={newUrl}
|
| 129 |
+
onInput={(e) => setNewUrl((e.target as HTMLInputElement).value)}
|
| 130 |
+
class="flex-[2] px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-transparent focus:outline-none focus:ring-1 focus:ring-primary font-mono"
|
| 131 |
+
/>
|
| 132 |
+
<button
|
| 133 |
+
onClick={handleAdd}
|
| 134 |
+
class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors whitespace-nowrap"
|
| 135 |
+
>
|
| 136 |
+
{t("addProxy")}
|
| 137 |
+
</button>
|
| 138 |
+
</div>
|
| 139 |
+
{addError && (
|
| 140 |
+
<p class="text-xs text-red-500 mt-2">{addError}</p>
|
| 141 |
+
)}
|
| 142 |
+
</div>
|
| 143 |
+
)}
|
| 144 |
+
|
| 145 |
+
{/* Empty state */}
|
| 146 |
+
{proxies.proxies.length === 0 && !showAdd && (
|
| 147 |
+
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-6 text-center text-sm text-slate-500 dark:text-text-dim">
|
| 148 |
+
{t("noProxies")}
|
| 149 |
+
</div>
|
| 150 |
+
)}
|
| 151 |
+
|
| 152 |
+
{/* Proxy list */}
|
| 153 |
+
{proxies.proxies.length > 0 && (
|
| 154 |
+
<div class="space-y-2">
|
| 155 |
+
{proxies.proxies.map((proxy) => {
|
| 156 |
+
const [statusCls, statusKey] =
|
| 157 |
+
statusStyles[proxy.status] || statusStyles.disabled;
|
| 158 |
+
const isChecking = checking === proxy.id;
|
| 159 |
+
|
| 160 |
+
return (
|
| 161 |
+
<div
|
| 162 |
+
key={proxy.id}
|
| 163 |
+
class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-3 hover:shadow-sm transition-all"
|
| 164 |
+
>
|
| 165 |
+
<div class="flex items-center justify-between">
|
| 166 |
+
{/* Left: name + url + status */}
|
| 167 |
+
<div class="flex items-center gap-3 min-w-0 flex-1">
|
| 168 |
+
<div class="min-w-0">
|
| 169 |
+
<div class="flex items-center gap-2">
|
| 170 |
+
<span class="font-medium text-sm truncate">
|
| 171 |
+
{proxy.name}
|
| 172 |
+
</span>
|
| 173 |
+
<span
|
| 174 |
+
class={`px-2 py-0.5 rounded-full text-[0.65rem] font-medium border ${statusCls}`}
|
| 175 |
+
>
|
| 176 |
+
{t(statusKey)}
|
| 177 |
+
</span>
|
| 178 |
+
</div>
|
| 179 |
+
<p class="text-xs text-slate-400 dark:text-text-dim font-mono truncate mt-0.5">
|
| 180 |
+
{maskUrl(proxy.url)}
|
| 181 |
+
</p>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
{/* Center: health info */}
|
| 186 |
+
{proxy.health && (
|
| 187 |
+
<div class="hidden sm:flex items-center gap-4 px-4 text-xs text-slate-500 dark:text-text-dim">
|
| 188 |
+
{proxy.health.exitIp && (
|
| 189 |
+
<span>
|
| 190 |
+
{t("exitIp")}: <span class="font-mono font-medium text-slate-700 dark:text-text-main">{proxy.health.exitIp}</span>
|
| 191 |
+
</span>
|
| 192 |
+
)}
|
| 193 |
+
<span>
|
| 194 |
+
{proxy.health.latencyMs}ms
|
| 195 |
+
</span>
|
| 196 |
+
{proxy.health.error && (
|
| 197 |
+
<span class="text-red-500 truncate max-w-[200px]" title={proxy.health.error}>
|
| 198 |
+
{proxy.health.error}
|
| 199 |
+
</span>
|
| 200 |
+
)}
|
| 201 |
+
</div>
|
| 202 |
+
)}
|
| 203 |
+
|
| 204 |
+
{/* Right: actions */}
|
| 205 |
+
<div class="flex items-center gap-1 ml-2">
|
| 206 |
+
<button
|
| 207 |
+
onClick={() => handleCheck(proxy.id)}
|
| 208 |
+
disabled={isChecking}
|
| 209 |
+
class="px-2 py-1 text-xs rounded-md hover:bg-slate-100 dark:hover:bg-border-dark transition-colors disabled:opacity-50"
|
| 210 |
+
title={t("checkHealth")}
|
| 211 |
+
>
|
| 212 |
+
{isChecking ? "..." : t("checkHealth")}
|
| 213 |
+
</button>
|
| 214 |
+
{proxy.status === "disabled" ? (
|
| 215 |
+
<button
|
| 216 |
+
onClick={() => proxies.enableProxy(proxy.id)}
|
| 217 |
+
class="px-2 py-1 text-xs rounded-md hover:bg-green-50 dark:hover:bg-green-900/20 text-green-600 transition-colors"
|
| 218 |
+
>
|
| 219 |
+
{t("enableProxy")}
|
| 220 |
+
</button>
|
| 221 |
+
) : (
|
| 222 |
+
<button
|
| 223 |
+
onClick={() => proxies.disableProxy(proxy.id)}
|
| 224 |
+
class="px-2 py-1 text-xs rounded-md hover:bg-slate-100 dark:hover:bg-border-dark text-slate-500 transition-colors"
|
| 225 |
+
>
|
| 226 |
+
{t("disableProxy")}
|
| 227 |
+
</button>
|
| 228 |
+
)}
|
| 229 |
+
<button
|
| 230 |
+
onClick={() => handleDelete(proxy.id)}
|
| 231 |
+
class="p-1 text-slate-400 dark:text-text-dim hover:text-red-500 transition-colors rounded-md hover:bg-red-50 dark:hover:bg-red-900/20"
|
| 232 |
+
title={t("deleteProxy")}
|
| 233 |
+
>
|
| 234 |
+
<svg
|
| 235 |
+
class="size-4"
|
| 236 |
+
viewBox="0 0 24 24"
|
| 237 |
+
fill="none"
|
| 238 |
+
stroke="currentColor"
|
| 239 |
+
stroke-width="1.5"
|
| 240 |
+
>
|
| 241 |
+
<path
|
| 242 |
+
stroke-linecap="round"
|
| 243 |
+
stroke-linejoin="round"
|
| 244 |
+
d="M6 18L18 6M6 6l12 12"
|
| 245 |
+
/>
|
| 246 |
+
</svg>
|
| 247 |
+
</button>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
{/* Mobile health info */}
|
| 252 |
+
{proxy.health && (
|
| 253 |
+
<div class="sm:hidden flex items-center gap-3 mt-2 text-xs text-slate-500 dark:text-text-dim">
|
| 254 |
+
{proxy.health.exitIp && (
|
| 255 |
+
<span>IP: <span class="font-mono">{proxy.health.exitIp}</span></span>
|
| 256 |
+
)}
|
| 257 |
+
<span>{proxy.health.latencyMs}ms</span>
|
| 258 |
+
</div>
|
| 259 |
+
)}
|
| 260 |
+
</div>
|
| 261 |
+
);
|
| 262 |
+
})}
|
| 263 |
+
</div>
|
| 264 |
+
)}
|
| 265 |
+
</section>
|
| 266 |
+
);
|
| 267 |
+
}
|