diff --git a/proxy.txt b/proxy.txt new file mode 100644 index 0000000..6d50863 --- /dev/null +++ b/proxy.txt @@ -0,0 +1,10 @@ +38.154.203.95:5863:eoimpusi:27ije2xpgvl9 +198.105.121.200:6462:eoimpusi:27ije2xpgvl9 +64.137.96.74:6641:eoimpusi:27ije2xpgvl9 +209.127.138.10:5784:eoimpusi:27ije2xpgvl9 +38.154.185.97:6370:eoimpusi:27ije2xpgvl9 +84.247.60.125:6095:eoimpusi:27ije2xpgvl9 +142.111.67.146:5611:eoimpusi:27ije2xpgvl9 +191.96.254.138:6185:eoimpusi:27ije2xpgvl9 +31.58.9.4:6077:eoimpusi:27ije2xpgvl9 +104.239.107.47:5699:eoimpusi:27ije2xpgvl9 \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 7d69782..de68072 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { import { checkBodySize } from "./middleware/body-limiter"; import { createRateLimiter } from "./middleware/rate-limiter"; import { logRelayEvent } from "./middleware/logger"; +import { ProxyPool } from "./lib/proxy-pool"; import type { Server, ServerWebSocket } from "bun"; @@ -50,6 +51,13 @@ const rateLimiter = createRateLimiter({ ), }); +// ─── Proxy pool (optional) ─────────────────────────────────────────────────────── + +const proxyPool = new ProxyPool(); +proxyPool.tryLoad( + process.env.PROXY_FILE || process.env.PROXY_LIST || "./proxy.txt", +); + // ─── WebSocket relay data type ────────────────────────────────────────────────── interface WSRelayData { @@ -340,28 +348,49 @@ async function handleRelay( req, filteredHeaders, RELAY_TIMEOUT_MS, - ); + ) as RequestInit & { proxy?: string }; const targetUrlString = targetUrl.toString(); - // ── Execute upstream fetch ─────────────────────────────────────── + // ── Attach proxy (if pool is loaded) ──────────────────────────── + const proxyUrl = proxyPool.getProxyUrl(); + if (proxyUrl) fetchOptions.proxy = proxyUrl; + + // ── Execute upstream fetch (with proxy retry on failure) ───────── let response: Response; - try { - response = await fetch(targetUrlString, fetchOptions); - } catch (err) { - const classified = classifyFetchError(err); - logRelayEvent({ - method, - url: requestUrl, - status: classified.status, - durationMs: Math.round(performance.now() - startTime), - error: classified.message, - targetUrl: targetUrlString, - ip: clientIP, - }); - return createErrorResponse(classified); + let retried = false; + + for (;;) { + try { + response = await fetch(targetUrlString, fetchOptions); + break; + } catch (err) { + // Rotate proxy on network failure and retry once + if (proxyPool.size > 0 && !retried) { + retried = true; + const next = proxyPool.markFailed(); + if (next) { + fetchOptions.proxy = proxyPool.getProxyUrl()!; + continue; + } + } + + const classified = classifyFetchError(err); + logRelayEvent({ + method, + url: requestUrl, + status: classified.status, + durationMs: Math.round(performance.now() - startTime), + error: classified.message, + targetUrl: targetUrlString, + ip: clientIP, + }); + return createErrorResponse(classified); + } } + if (proxyUrl) proxyPool.markSuccess(); + // ── Build relay response ───────────────────────────────────────── const relayedResponse = createRelayResponse(response); diff --git a/src/lib/proxy-pool.ts b/src/lib/proxy-pool.ts new file mode 100644 index 0000000..225c0a4 --- /dev/null +++ b/src/lib/proxy-pool.ts @@ -0,0 +1,174 @@ +/** + * Auto-rotating proxy pool. + * + * Reads proxies from a text file (format: host:port:username:password), + * rotates through them round-robin, and tracks failures so unhealthy + * proxies are skipped. + */ + +import { readFileSync, existsSync } from "node:fs"; + +// ─── Types ─────────────────────────────────────────────────────────────────────── + +export interface ProxyEntry { + host: string; + port: number; + username: string; + password: string; +} + +// ─── ProxyPool ─────────────────────────────────────────────────────────────────── + +export class ProxyPool { + private proxies: ProxyEntry[] = []; + private currentIndex = 0; + private failureThreshold = 3; + /** host:port → consecutive failure count */ + private failures = new Map(); + + // ── Load ────────────────────────────────────────────────────────── + + /** + * Load proxies from a file at `filePath`. + * + * Expected format — one proxy per line: + * host:port:username:password + * + * Lines starting with `#` are ignored as comments. + */ + load(filePath: string): void { + if (!existsSync(filePath)) { + console.warn(`[proxy-pool] File not found: ${filePath}`); + return; + } + + const text = readFileSync(filePath, "utf-8"); + const lines = text + .split("\n") + .map((l: string) => l.trim()) + .filter(Boolean); + + const parsed: ProxyEntry[] = []; + for (const line of lines) { + if (line.startsWith("#")) continue; + + const parts = line.split(":"); + if (parts.length < 2) continue; + + const host = parts[0]!; + const port = Number.parseInt(parts[1]!, 10); + if (!Number.isFinite(port)) continue; + + parsed.push({ + host, + port, + username: parts[2] ?? "", + password: parts.slice(3).join(":") ?? "", + }); + } + + this.proxies = parsed; + this.currentIndex = 0; + this.failures.clear(); + + if (this.proxies.length > 0) { + console.log( + `[proxy-pool] Loaded ${this.proxies.length} proxies from ${filePath}`, + ); + } + } + + /** + * Convenience — load from a path or skip. + * Returns `true` if proxies were loaded. + */ + tryLoad(filePath?: string): boolean { + if (filePath) this.load(filePath); + return this.proxies.length > 0; + } + + // ── Access ──────────────────────────────────────────────────────── + + /** Total number of proxies in the pool. */ + get size(): number { + return this.proxies.length; + } + + /** + * Return the current proxy entry. + * Returns `null` if pool is empty. + */ + getCurrent(): ProxyEntry | null { + if (this.proxies.length === 0) return null; + return this.proxies[this.currentIndex] ?? null; + } + + /** + * Return the proxy URL string for `fetch()`'s `proxy` option. + * + * Format: `http://username:password@host:port` + * Returns `null` when the pool is empty. + */ + getProxyUrl(): string | null { + const entry = this.getCurrent(); + if (!entry) return null; + return this.formatProxyUrl(entry); + } + + /** Build a `http://user:pass@host:port` URL from an entry. */ + private formatProxyUrl(entry: ProxyEntry): string { + const auth = entry.username + ? `${encodeURIComponent(entry.username)}:${encodeURIComponent(entry.password)}@` + : ""; + return `http://${auth}${entry.host}:${entry.port}`; + } + + // ── Rotation ────────────────────────────────────────────────────── + + /** + * Advance to the next proxy (round-robin, wraps around). + * Returns the new current proxy or `null` if the pool is empty. + */ + rotate(): ProxyEntry | null { + if (this.proxies.length === 0) return null; + this.currentIndex = (this.currentIndex + 1) % this.proxies.length; + return this.proxies[this.currentIndex] ?? null; + } + + /** + * Mark the **current** proxy as failed. + * + * If the failure count exceeds `threshold`, the proxy is skipped and + * rotation continues until a healthy proxy is found (or we've tried + * all of them). + */ + markFailed(threshold?: number): ProxyEntry | null { + const entry = this.getCurrent(); + if (!entry) return null; + + const key = `${entry.host}:${entry.port}`; + const count = (this.failures.get(key) ?? 0) + 1; + this.failures.set(key, count); + + const th = threshold ?? this.failureThreshold; + if (count >= th) { + console.warn( + `[proxy-pool] Proxy ${key} failed ${count}/${th} times — skipping`, + ); + } + + return this.rotate(); + } + + /** Reset the failure counter for the current proxy. */ + markSuccess(): void { + const entry = this.getCurrent(); + if (!entry) return; + this.failures.delete(`${entry.host}:${entry.port}`); + } + + /** Set the failure count that triggers a permanent skip. */ + setFailureThreshold(n: number): void { + this.failureThreshold = n; + } +}