Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
deae086
1
Parent(s): 4ebb914
fix: proxy pool review fixes — security, validation, robustness
Browse files- Skip disabled proxies in healthCheckAll (avoid unnecessary network calls)
- Validate proxy URL scheme (only http/https/socks5/socks5h allowed)
- Deduplicate proxy URLs on add (return existing ID if URL already exists)
- Mask proxy credentials server-side via getAllMasked() (remove client-side maskUrl)
- Add try-catch to all use-proxies hook operations (prevent unhandled rejections)
- Fix misleading route ordering comment in proxies.ts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- shared/hooks/use-proxies.ts +51 -23
- src/proxy/proxy-pool.ts +31 -9
- src/routes/proxies.ts +11 -6
- web/src/components/ProxyPool.tsx +1 -13
shared/hooks/use-proxies.ts
CHANGED
|
@@ -82,24 +82,36 @@ export function useProxies(): ProxiesState {
|
|
| 82 |
|
| 83 |
const checkProxy = useCallback(
|
| 84 |
async (id: string) => {
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
await refresh();
|
| 89 |
},
|
| 90 |
[refresh],
|
| 91 |
);
|
| 92 |
|
| 93 |
const checkAll = useCallback(async () => {
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
await refresh();
|
| 96 |
}, [refresh]);
|
| 97 |
|
| 98 |
const enableProxy = useCallback(
|
| 99 |
async (id: string) => {
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
await refresh();
|
| 104 |
},
|
| 105 |
[refresh],
|
|
@@ -107,9 +119,13 @@ export function useProxies(): ProxiesState {
|
|
| 107 |
|
| 108 |
const disableProxy = useCallback(
|
| 109 |
async (id: string) => {
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
await refresh();
|
| 114 |
},
|
| 115 |
[refresh],
|
|
@@ -117,11 +133,15 @@ export function useProxies(): ProxiesState {
|
|
| 117 |
|
| 118 |
const assignProxy = useCallback(
|
| 119 |
async (accountId: string, proxyId: string) => {
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
await refresh();
|
| 126 |
},
|
| 127 |
[refresh],
|
|
@@ -129,9 +149,13 @@ export function useProxies(): ProxiesState {
|
|
| 129 |
|
| 130 |
const unassignProxy = useCallback(
|
| 131 |
async (accountId: string) => {
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
await refresh();
|
| 136 |
},
|
| 137 |
[refresh],
|
|
@@ -139,11 +163,15 @@ export function useProxies(): ProxiesState {
|
|
| 139 |
|
| 140 |
const setIntervalMinutes = useCallback(
|
| 141 |
async (minutes: number) => {
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
await refresh();
|
| 148 |
},
|
| 149 |
[refresh],
|
|
|
|
| 82 |
|
| 83 |
const checkProxy = useCallback(
|
| 84 |
async (id: string) => {
|
| 85 |
+
try {
|
| 86 |
+
await fetch(`/api/proxies/${encodeURIComponent(id)}/check`, {
|
| 87 |
+
method: "POST",
|
| 88 |
+
});
|
| 89 |
+
} catch {
|
| 90 |
+
// network error — refresh will show stale state
|
| 91 |
+
}
|
| 92 |
await refresh();
|
| 93 |
},
|
| 94 |
[refresh],
|
| 95 |
);
|
| 96 |
|
| 97 |
const checkAll = useCallback(async () => {
|
| 98 |
+
try {
|
| 99 |
+
await fetch("/api/proxies/check-all", { method: "POST" });
|
| 100 |
+
} catch {
|
| 101 |
+
// network error
|
| 102 |
+
}
|
| 103 |
await refresh();
|
| 104 |
}, [refresh]);
|
| 105 |
|
| 106 |
const enableProxy = useCallback(
|
| 107 |
async (id: string) => {
|
| 108 |
+
try {
|
| 109 |
+
await fetch(`/api/proxies/${encodeURIComponent(id)}/enable`, {
|
| 110 |
+
method: "POST",
|
| 111 |
+
});
|
| 112 |
+
} catch {
|
| 113 |
+
// network error
|
| 114 |
+
}
|
| 115 |
await refresh();
|
| 116 |
},
|
| 117 |
[refresh],
|
|
|
|
| 119 |
|
| 120 |
const disableProxy = useCallback(
|
| 121 |
async (id: string) => {
|
| 122 |
+
try {
|
| 123 |
+
await fetch(`/api/proxies/${encodeURIComponent(id)}/disable`, {
|
| 124 |
+
method: "POST",
|
| 125 |
+
});
|
| 126 |
+
} catch {
|
| 127 |
+
// network error
|
| 128 |
+
}
|
| 129 |
await refresh();
|
| 130 |
},
|
| 131 |
[refresh],
|
|
|
|
| 133 |
|
| 134 |
const assignProxy = useCallback(
|
| 135 |
async (accountId: string, proxyId: string) => {
|
| 136 |
+
try {
|
| 137 |
+
await fetch("/api/proxies/assign", {
|
| 138 |
+
method: "POST",
|
| 139 |
+
headers: { "Content-Type": "application/json" },
|
| 140 |
+
body: JSON.stringify({ accountId, proxyId }),
|
| 141 |
+
});
|
| 142 |
+
} catch {
|
| 143 |
+
// network error
|
| 144 |
+
}
|
| 145 |
await refresh();
|
| 146 |
},
|
| 147 |
[refresh],
|
|
|
|
| 149 |
|
| 150 |
const unassignProxy = useCallback(
|
| 151 |
async (accountId: string) => {
|
| 152 |
+
try {
|
| 153 |
+
await fetch(`/api/proxies/assign/${encodeURIComponent(accountId)}`, {
|
| 154 |
+
method: "DELETE",
|
| 155 |
+
});
|
| 156 |
+
} catch {
|
| 157 |
+
// network error
|
| 158 |
+
}
|
| 159 |
await refresh();
|
| 160 |
},
|
| 161 |
[refresh],
|
|
|
|
| 163 |
|
| 164 |
const setIntervalMinutes = useCallback(
|
| 165 |
async (minutes: number) => {
|
| 166 |
+
try {
|
| 167 |
+
await fetch("/api/proxies/settings", {
|
| 168 |
+
method: "PUT",
|
| 169 |
+
headers: { "Content-Type": "application/json" },
|
| 170 |
+
body: JSON.stringify({ healthCheckIntervalMinutes: minutes }),
|
| 171 |
+
});
|
| 172 |
+
} catch {
|
| 173 |
+
// network error
|
| 174 |
+
}
|
| 175 |
await refresh();
|
| 176 |
},
|
| 177 |
[refresh],
|
src/proxy/proxy-pool.ts
CHANGED
|
@@ -78,11 +78,18 @@ export class ProxyPool {
|
|
| 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:
|
| 86 |
status: "active",
|
| 87 |
health: null,
|
| 88 |
addedAt: new Date().toISOString(),
|
|
@@ -121,6 +128,11 @@ export class ProxyPool {
|
|
| 121 |
return Array.from(this.proxies.values());
|
| 122 |
}
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
getById(id: string): ProxyEntry | undefined {
|
| 125 |
return this.proxies.get(id);
|
| 126 |
}
|
|
@@ -285,17 +297,17 @@ export class ProxyPool {
|
|
| 285 |
}
|
| 286 |
|
| 287 |
async healthCheckAll(): Promise<void> {
|
| 288 |
-
const
|
| 289 |
-
|
|
|
|
|
|
|
| 290 |
|
| 291 |
-
console.log(`[ProxyPool] Health checking ${
|
| 292 |
-
await Promise.allSettled(
|
| 293 |
|
| 294 |
-
const active =
|
| 295 |
-
(p) => p.status === "active",
|
| 296 |
-
).length;
|
| 297 |
console.log(
|
| 298 |
-
`[ProxyPool] Health check complete: ${active}/${
|
| 299 |
);
|
| 300 |
}
|
| 301 |
|
|
@@ -438,6 +450,16 @@ export class ProxyPool {
|
|
| 438 |
|
| 439 |
// ── Helpers ─────────────────────────────────────────────────────────
|
| 440 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
function randomHex(bytes: number): string {
|
| 442 |
const arr = new Uint8Array(bytes);
|
| 443 |
crypto.getRandomValues(arr);
|
|
|
|
| 78 |
// ── CRUD ──────────────────────────────────────────────────────────
|
| 79 |
|
| 80 |
add(name: string, url: string): string {
|
| 81 |
+
const trimmedUrl = url.trim();
|
| 82 |
+
// Reject duplicate URLs
|
| 83 |
+
for (const existing of this.proxies.values()) {
|
| 84 |
+
if (existing.url === trimmedUrl) {
|
| 85 |
+
return existing.id;
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
const id = randomHex(8);
|
| 89 |
const entry: ProxyEntry = {
|
| 90 |
id,
|
| 91 |
name: name.trim(),
|
| 92 |
+
url: trimmedUrl,
|
| 93 |
status: "active",
|
| 94 |
health: null,
|
| 95 |
addedAt: new Date().toISOString(),
|
|
|
|
| 128 |
return Array.from(this.proxies.values());
|
| 129 |
}
|
| 130 |
|
| 131 |
+
/** Returns all proxies with credentials masked in URLs. */
|
| 132 |
+
getAllMasked(): ProxyEntry[] {
|
| 133 |
+
return this.getAll().map((p) => ({ ...p, url: maskProxyUrl(p.url) }));
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
getById(id: string): ProxyEntry | undefined {
|
| 137 |
return this.proxies.get(id);
|
| 138 |
}
|
|
|
|
| 297 |
}
|
| 298 |
|
| 299 |
async healthCheckAll(): Promise<void> {
|
| 300 |
+
const targets = Array.from(this.proxies.values()).filter(
|
| 301 |
+
(p) => p.status !== "disabled",
|
| 302 |
+
);
|
| 303 |
+
if (targets.length === 0) return;
|
| 304 |
|
| 305 |
+
console.log(`[ProxyPool] Health checking ${targets.length} proxies...`);
|
| 306 |
+
await Promise.allSettled(targets.map((p) => this.healthCheck(p.id)));
|
| 307 |
|
| 308 |
+
const active = targets.filter((p) => p.status === "active").length;
|
|
|
|
|
|
|
| 309 |
console.log(
|
| 310 |
+
`[ProxyPool] Health check complete: ${active}/${targets.length} active`,
|
| 311 |
);
|
| 312 |
}
|
| 313 |
|
|
|
|
| 450 |
|
| 451 |
// ── Helpers ─────────────────────────────────────────────────────────
|
| 452 |
|
| 453 |
+
function maskProxyUrl(url: string): string {
|
| 454 |
+
try {
|
| 455 |
+
const u = new URL(url);
|
| 456 |
+
if (u.password) u.password = "***";
|
| 457 |
+
return u.toString();
|
| 458 |
+
} catch {
|
| 459 |
+
return url;
|
| 460 |
+
}
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
function randomHex(bytes: number): string {
|
| 464 |
const arr = new Uint8Array(bytes);
|
| 465 |
crypto.getRandomValues(arr);
|
src/routes/proxies.ts
CHANGED
|
@@ -20,10 +20,10 @@ import type { ProxyPool } from "../proxy/proxy-pool.js";
|
|
| 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.
|
| 27 |
assignments: proxyPool.getAllAssignments(),
|
| 28 |
healthCheckIntervalMinutes: proxyPool.getHealthIntervalMinutes(),
|
| 29 |
});
|
|
@@ -39,9 +39,14 @@ export function createProxyRoutes(proxyPool: ProxyPool): Hono {
|
|
| 39 |
return c.json({ error: "url is required" });
|
| 40 |
}
|
| 41 |
|
| 42 |
-
//
|
| 43 |
try {
|
| 44 |
-
new URL(url);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
} catch {
|
| 46 |
c.status(400);
|
| 47 |
return c.json({ error: "Invalid proxy URL format" });
|
|
@@ -114,12 +119,12 @@ export function createProxyRoutes(proxyPool: ProxyPool): Hono {
|
|
| 114 |
return c.json({ success: true, proxy: proxyPool.getById(id) });
|
| 115 |
});
|
| 116 |
|
| 117 |
-
// Health check all —
|
| 118 |
app.post("/api/proxies/check-all", async (c) => {
|
| 119 |
await proxyPool.healthCheckAll();
|
| 120 |
return c.json({
|
| 121 |
success: true,
|
| 122 |
-
proxies: proxyPool.
|
| 123 |
});
|
| 124 |
});
|
| 125 |
|
|
|
|
| 20 |
export function createProxyRoutes(proxyPool: ProxyPool): Hono {
|
| 21 |
const app = new Hono();
|
| 22 |
|
| 23 |
+
// List all proxies + assignments (credentials masked)
|
| 24 |
app.get("/api/proxies", (c) => {
|
| 25 |
return c.json({
|
| 26 |
+
proxies: proxyPool.getAllMasked(),
|
| 27 |
assignments: proxyPool.getAllAssignments(),
|
| 28 |
healthCheckIntervalMinutes: proxyPool.getHealthIntervalMinutes(),
|
| 29 |
});
|
|
|
|
| 39 |
return c.json({ error: "url is required" });
|
| 40 |
}
|
| 41 |
|
| 42 |
+
// URL validation + scheme check
|
| 43 |
try {
|
| 44 |
+
const parsed = new URL(url);
|
| 45 |
+
const allowed = ["http:", "https:", "socks5:", "socks5h:"];
|
| 46 |
+
if (!allowed.includes(parsed.protocol)) {
|
| 47 |
+
c.status(400);
|
| 48 |
+
return c.json({ error: `Unsupported protocol "${parsed.protocol}". Use http, https, socks5, or socks5h.` });
|
| 49 |
+
}
|
| 50 |
} catch {
|
| 51 |
c.status(400);
|
| 52 |
return c.json({ error: "Invalid proxy URL format" });
|
|
|
|
| 119 |
return c.json({ success: true, proxy: proxyPool.getById(id) });
|
| 120 |
});
|
| 121 |
|
| 122 |
+
// Health check all (no route conflict — different path structure from /:id/*)
|
| 123 |
app.post("/api/proxies/check-all", async (c) => {
|
| 124 |
await proxyPool.healthCheckAll();
|
| 125 |
return c.json({
|
| 126 |
success: true,
|
| 127 |
+
proxies: proxyPool.getAllMasked(),
|
| 128 |
});
|
| 129 |
});
|
| 130 |
|
web/src/components/ProxyPool.tsx
CHANGED
|
@@ -18,18 +18,6 @@ const statusStyles: Record<string, [string, TranslationKey]> = {
|
|
| 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 |
}
|
|
@@ -177,7 +165,7 @@ export function ProxyPool({ proxies }: ProxyPoolProps) {
|
|
| 177 |
</span>
|
| 178 |
</div>
|
| 179 |
<p class="text-xs text-slate-400 dark:text-text-dim font-mono truncate mt-0.5">
|
| 180 |
-
{
|
| 181 |
</p>
|
| 182 |
</div>
|
| 183 |
</div>
|
|
|
|
| 18 |
],
|
| 19 |
};
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
interface ProxyPoolProps {
|
| 22 |
proxies: ProxiesState;
|
| 23 |
}
|
|
|
|
| 165 |
</span>
|
| 166 |
</div>
|
| 167 |
<p class="text-xs text-slate-400 dark:text-text-dim font-mono truncate mt-0.5">
|
| 168 |
+
{proxy.url}
|
| 169 |
</p>
|
| 170 |
</div>
|
| 171 |
</div>
|