From f3226b7f8d5dd9ae18d41051153dd2a034125a01 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Fri, 1 May 2026 18:36:07 +0700 Subject: [PATCH] feat: integrate 1proxy free proxy marketplace (closes #1788) (#1847) Integrated into release/v3.7.8 --- open-sse/mcp-server/schemas/tools.ts | 112 ++++++ open-sse/mcp-server/server.ts | 44 +++ open-sse/mcp-server/tools/advancedTools.ts | 116 ++++++ .../settings/components/OneproxyTab.tsx | 330 ++++++++++++++++++ .../settings/components/ProxyTab.tsx | 2 + src/app/api/settings/oneproxy/rotate/route.ts | 44 +++ src/app/api/settings/oneproxy/route.ts | 126 +++++++ .../migrations/040_oneproxy_proxy_fields.sql | 24 ++ src/lib/db/oneproxy.ts | 293 ++++++++++++++++ src/lib/localDb.ts | 14 + src/lib/oneproxyRotator.ts | 19 + src/lib/oneproxySync.ts | 182 ++++++++++ src/shared/constants/mcpScopes.ts | 4 + src/shared/validation/oneproxySchemas.ts | 14 + 14 files changed, 1324 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx create mode 100644 src/app/api/settings/oneproxy/rotate/route.ts create mode 100644 src/app/api/settings/oneproxy/route.ts create mode 100644 src/lib/db/migrations/040_oneproxy_proxy_fields.sql create mode 100644 src/lib/db/oneproxy.ts create mode 100644 src/lib/oneproxyRotator.ts create mode 100644 src/lib/oneproxySync.ts create mode 100644 src/shared/validation/oneproxySchemas.ts diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 57052860c3..401f39d3d5 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -1072,6 +1072,115 @@ export const compressionConfigureTool: McpToolDefinition< sourceEndpoints: ["/api/compression/configure"], }; +// ============ 1proxy Tools ============ + +export const oneproxyFetchInput = z.object({ + protocol: z.string().optional().describe("Filter by protocol: http, https, socks4, socks5"), + countryCode: z.string().optional().describe("Filter by country code (e.g. US, DE)"), + minQuality: z.number().optional().describe("Minimum quality score (0-100)"), + limit: z.number().optional().describe("Maximum number of proxies to return"), +}); + +export const oneproxyFetchOutput = z.object({ + items: z.array( + z.object({ + id: z.string(), + host: z.string(), + port: z.number(), + type: z.string(), + countryCode: z.string().nullable(), + qualityScore: z.number().nullable(), + latencyMs: z.number().nullable(), + anonymity: z.string().nullable(), + googleAccess: z.boolean(), + status: z.string(), + }) + ), + total: z.number(), +}); + +export const oneproxyFetchTool: McpToolDefinition< + typeof oneproxyFetchInput, + typeof oneproxyFetchOutput +> = { + name: "omniroute_oneproxy_fetch", + description: + "Fetch free proxies from the 1proxy marketplace with optional filters for protocol, country, and quality. Returns validated proxies with quality scores.", + inputSchema: oneproxyFetchInput, + outputSchema: oneproxyFetchOutput, + scopes: ["read:proxies"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/settings/oneproxy"], +}; + +export const oneproxyRotateInput = z.object({ + strategy: z + .enum(["random", "quality", "sequential"]) + .optional() + .describe("Rotation strategy: quality (best first), random, or sequential"), +}); + +export const oneproxyRotateOutput = z.object({ + id: z.string(), + host: z.string(), + port: z.number(), + type: z.string(), + countryCode: z.string().nullable(), + qualityScore: z.number().nullable(), + latencyMs: z.number().nullable(), +}); + +export const oneproxyRotateTool: McpToolDefinition< + typeof oneproxyRotateInput, + typeof oneproxyRotateOutput +> = { + name: "omniroute_oneproxy_rotate", + description: + "Get the next available free proxy from the 1proxy pool using the specified rotation strategy.", + inputSchema: oneproxyRotateInput, + outputSchema: oneproxyRotateOutput, + scopes: ["read:proxies"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/settings/oneproxy/rotate"], +}; + +export const oneproxyStatsInput = z.object({}).describe("No parameters required"); + +export const oneproxyStatsOutput = z.object({ + stats: z.object({ + total: z.number(), + active: z.number(), + avgQuality: z.number().nullable(), + lastValidated: z.string().nullable(), + byProtocol: z.array(z.object({ protocol: z.string(), count: z.number() })), + byCountry: z.array(z.object({ countryCode: z.string(), count: z.number() })), + }), + status: z.object({ + lastSyncSuccess: z.boolean(), + lastSyncError: z.string().nullable(), + lastSyncAt: z.string().nullable(), + lastSyncCount: z.number(), + consecutiveFailures: z.number(), + }), +}); + +export const oneproxyStatsTool: McpToolDefinition< + typeof oneproxyStatsInput, + typeof oneproxyStatsOutput +> = { + name: "omniroute_oneproxy_stats", + description: + "Returns 1proxy sync status and statistics: total proxies, average quality, sync history, and distribution by protocol and country.", + inputSchema: oneproxyStatsInput, + outputSchema: oneproxyStatsOutput, + scopes: ["read:proxies"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/settings/oneproxy"], +}; + // ============ Tool Registry ============ /** All MCP tool definitions, ordered by phase then name */ @@ -1100,6 +1209,9 @@ export const MCP_TOOLS = [ cacheFlushTool, compressionStatusTool, compressionConfigureTool, + oneproxyFetchTool, + oneproxyRotateTool, + oneproxyStatsTool, ] as const; /** Essential tools only (Phase 1) */ diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 7d6540e01c..7b7d7d9c7f 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -42,6 +42,9 @@ import { syncPricingInput, cacheStatsInput, cacheFlushInput, + oneproxyFetchInput, + oneproxyRotateInput, + oneproxyStatsInput, } from "./schemas/tools.ts"; import { startMcpHeartbeat } from "./runtimeHeartbeat.ts"; @@ -66,6 +69,9 @@ import { handleSyncPricing, handleCacheStats, handleCacheFlush, + handleOneproxyFetch, + handleOneproxyRotate, + handleOneproxyStats, } from "./tools/advancedTools.ts"; import { memoryTools } from "./tools/memoryTools.ts"; import { skillTools } from "./tools/skillTools.ts"; @@ -832,6 +838,44 @@ export function createMcpServer(): McpServer { ) ); + // ── 1proxy Tools ────────────────────────────── + + server.registerTool( + "omniroute_oneproxy_fetch", + { + description: + "Fetch free proxies from the 1proxy marketplace with optional filters for protocol, country, and quality. Returns validated proxies with quality scores.", + inputSchema: oneproxyFetchInput, + }, + withScopeEnforcement("omniroute_oneproxy_fetch", (args) => + handleOneproxyFetch(oneproxyFetchInput.parse(args)) + ) + ); + + server.registerTool( + "omniroute_oneproxy_rotate", + { + description: + "Get the next available free proxy from the 1proxy pool using the specified rotation strategy.", + inputSchema: oneproxyRotateInput, + }, + withScopeEnforcement("omniroute_oneproxy_rotate", (args) => + handleOneproxyRotate(oneproxyRotateInput.parse(args)) + ) + ); + + server.registerTool( + "omniroute_oneproxy_stats", + { + description: + "Returns 1proxy sync status and statistics: total proxies, average quality, sync history, and distribution by protocol and country.", + inputSchema: oneproxyStatsInput, + }, + withScopeEnforcement("omniroute_oneproxy_stats", (args) => + handleOneproxyStats(oneproxyStatsInput.parse(args)) + ) + ); + // ── Memory Tools ────────────────────────────── Object.values(memoryTools).forEach((toolDef) => { server.registerTool( diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index 9769244f88..2275990db2 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -1000,3 +1000,119 @@ export async function handleCacheFlush(args: { signature?: string; model?: strin return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; } } + +// ============ 1proxy Tools ============ + +export async function handleOneproxyFetch( + args: { protocol?: string; countryCode?: string; minQuality?: number; limit?: number } = {} +) { + const start = Date.now(); + try { + const params = new URLSearchParams(); + if (args.protocol) params.set("protocol", args.protocol); + if (args.countryCode) params.set("countryCode", args.countryCode); + if (args.minQuality) params.set("minQuality", String(args.minQuality)); + if (args.limit) params.set("limit", String(args.limit)); + + const query = params.toString(); + const path = query ? `/api/settings/oneproxy?${query}` : "/api/settings/oneproxy"; + const raw = toRecord(await apiFetch(path)); + + const items = toArrayOfRecords(raw.items).map((r) => ({ + id: toString(r.id, ""), + host: toString(r.host, ""), + port: toNumber(r.port, 0), + type: toString(r.type, "http"), + countryCode: typeof r.country_code === "string" ? r.country_code : null, + qualityScore: r.quality_score != null ? toNumber(r.quality_score) : null, + latencyMs: r.latency_ms != null ? toNumber(r.latency_ms) : null, + anonymity: typeof r.anonymity === "string" ? r.anonymity : null, + googleAccess: r.google_access === 1 || r.google_access === true, + status: toString(r.status, "active"), + })); + + const result = { items, total: toNumber(raw.total, items.length) }; + await logToolCall("omniroute_oneproxy_fetch", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_oneproxy_fetch", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +export async function handleOneproxyRotate( + args: { strategy?: "random" | "quality" | "sequential" } = {} +) { + const start = Date.now(); + try { + const body: Record = {}; + if (args.strategy) body.strategy = args.strategy; + + const raw = toRecord( + await apiFetch("/api/settings/oneproxy/rotate", { + method: "POST", + body: JSON.stringify(body), + }) + ); + + const result = { + id: toString(raw.id, ""), + host: toString(raw.host, ""), + port: toNumber(raw.port, 0), + type: toString(raw.type, "http"), + countryCode: typeof raw.country_code === "string" ? raw.country_code : null, + qualityScore: raw.quality_score != null ? toNumber(raw.quality_score) : null, + latencyMs: raw.latency_ms != null ? toNumber(raw.latency_ms) : null, + }; + + await logToolCall("omniroute_oneproxy_rotate", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_oneproxy_rotate", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + +export async function handleOneproxyStats(args: Record = {}) { + const start = Date.now(); + try { + const raw = toRecord(await apiFetch("/api/settings/oneproxy?action=stats")); + + const statsRaw = toRecord(raw.stats); + const statusRaw = toRecord(raw.status); + + const stats = { + total: toNumber(statsRaw.total, 0), + active: toNumber(statsRaw.active, 0), + avgQuality: statsRaw.avg_quality != null ? toNumber(statsRaw.avg_quality) : null, + lastValidated: typeof statsRaw.last_validated === "string" ? statsRaw.last_validated : null, + byProtocol: toArrayOfRecords(statsRaw.by_protocol || statsRaw.byProtocol).map((r) => ({ + protocol: toString(r.protocol, ""), + count: toNumber(r.count, 0), + })), + byCountry: toArrayOfRecords(statsRaw.by_country || statsRaw.byCountry).map((r) => ({ + countryCode: toString(r.countryCode || r.country_code, ""), + count: toNumber(r.count, 0), + })), + }; + + const status = { + lastSyncSuccess: toBoolean(statusRaw.last_sync_success, false), + lastSyncError: + typeof statusRaw.last_sync_error === "string" ? statusRaw.last_sync_error : null, + lastSyncAt: typeof statusRaw.last_sync_at === "string" ? statusRaw.last_sync_at : null, + lastSyncCount: toNumber(statusRaw.last_sync_count, 0), + consecutiveFailures: toNumber(statusRaw.consecutive_failures, 0), + }; + + const result = { stats, status }; + await logToolCall("omniroute_oneproxy_stats", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_oneproxy_stats", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} diff --git a/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx new file mode 100644 index 0000000000..b9b16520f6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/OneproxyTab.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Card } from "@/shared/components"; + +type OneproxyItem = { + id: string; + name: string; + host: string; + port: number; + type: string; + countryCode: string | null; + qualityScore: number | null; + latencyMs: number | null; + anonymity: string | null; + googleAccess: boolean; + status: string; + lastValidated: string | null; +}; + +type OneproxyStats = { + total: number; + active: number; + avgQuality: number | null; + lastValidated: string | null; + byProtocol: Array<{ protocol: string; count: number }>; + byCountry: Array<{ countryCode: string; count: number }>; +}; + +type SyncStatus = { + lastSyncSuccess: boolean; + lastSyncError: string | null; + lastSyncAt: string | null; + lastSyncCount: number; + consecutiveFailures: number; +}; + +export default function OneproxyTab() { + const t = useTranslations("settings"); + const [proxies, setProxies] = useState([]); + const [stats, setStats] = useState(null); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + const [filterProtocol, setFilterProtocol] = useState(""); + const [filterCountry, setFilterCountry] = useState(""); + const [minQuality, setMinQuality] = useState(""); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + if (filterProtocol) params.set("protocol", filterProtocol); + if (filterCountry) params.set("countryCode", filterCountry); + if (minQuality) params.set("minQuality", minQuality); + + const [proxiesRes, statsRes] = await Promise.all([ + fetch(`/api/settings/oneproxy?${params.toString()}`), + fetch("/api/settings/oneproxy?action=stats"), + ]); + + if (proxiesRes.ok) { + const data = await proxiesRes.json(); + setProxies(data.items || []); + } + if (statsRes.ok) { + const data = await statsRes.json(); + setStats(data.stats); + setStatus(data.status); + } + } catch { + } finally { + setLoading(false); + } + }, [filterProtocol, filterCountry, minQuality]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handleSync = async () => { + setSyncing(true); + setSyncResult(null); + try { + const res = await fetch("/api/settings/oneproxy", { method: "POST" }); + const data = await res.json(); + if (data.success) { + setSyncResult(`Synced ${data.total} proxies (${data.added} new, ${data.updated} updated)`); + } else { + setSyncResult(`Sync failed: ${data.error}`); + } + await loadData(); + } catch (err) { + setSyncResult(`Sync failed: ${err}`); + } finally { + setSyncing(false); + } + }; + + const handleClearAll = async () => { + if (!confirm("Clear all 1proxy proxies?")) return; + try { + await fetch("/api/settings/oneproxy?clearAll=1", { method: "DELETE" }); + await loadData(); + } catch { + // ignore + } + }; + + const handleDelete = async (id: string) => { + try { + await fetch(`/api/settings/oneproxy?id=${id}`, { method: "DELETE" }); + setProxies((prev) => prev.filter((p) => p.id !== id)); + if (stats) setStats({ ...stats, total: stats.total - 1, active: stats.active - 1 }); + } catch { + // ignore + } + }; + + const qualityColor = (score: number | null) => { + if (score == null) return "bg-gray-500"; + if (score >= 80) return "bg-green-500"; + if (score >= 50) return "bg-yellow-500"; + return "bg-red-500"; + }; + + const protocolBadge = (type: string) => { + const colors: Record = { + http: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + https: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + socks4: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200", + socks5: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200", + }; + return colors[type] || "bg-gray-100 text-gray-800"; + }; + + return ( +
+
+
+

1proxy Free Proxy Marketplace

+

+ Fetch and rotate free validated proxies from the 1proxy community platform +

+
+
+ + {proxies.length > 0 && ( + + )} +
+
+ + {syncResult && ( +
+ {syncResult} +
+ )} + + {stats && ( +
+ +
{stats.total}
+
Total Proxies
+
+ +
{stats.active}
+
Active
+
+ +
+ {stats.avgQuality != null ? `${stats.avgQuality}` : "—"} +
+
Avg Quality
+
+ +
+ {status?.lastSyncAt ? new Date(status.lastSyncAt).toLocaleTimeString() : "Never"} +
+
Last Sync
+
+
+ )} + + +
+ + setFilterCountry(e.target.value)} + className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-40" + /> + setMinQuality(e.target.value)} + className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-text-main text-sm border border-border w-32" + /> +
+
+ + + {loading ? ( +
Loading proxies...
+ ) : proxies.length === 0 ? ( +
+ No 1proxy proxies found. Click "Sync Now" to fetch free proxies. +
+ ) : ( +
+ + + + + + + + + + + + + + + {proxies.map((proxy) => ( + + + + + + + + + + + ))} + +
HostProtocolCountryQualityLatencyAnonymityGoogleActions
+ {proxy.host}:{proxy.port} + + + {proxy.type.toUpperCase()} + + {proxy.countryCode || "—"} +
+
+ {proxy.qualityScore ?? "—"} +
+
+ {proxy.latencyMs != null ? `${proxy.latencyMs}ms` : "—"} + {proxy.anonymity || "—"} + {proxy.googleAccess ? ( + + ) : ( + + )} + + +
+
+ )} +
+ + {status && ( + +

Sync Status

+
+
+ Last sync: + + {status.lastSyncSuccess ? "Success" : "Failed"} + +
+
+ Proxies fetched: + {status.lastSyncCount} +
+
+ Consecutive failures: + {status.consecutiveFailures} +
+ {status.lastSyncError && ( +
+ Error: + {status.lastSyncError} +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx index 8fe96b9ac8..6925fa57d0 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyTab.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ProxyConfigModal, Toggle } from "@/shared/components"; import { useTranslations } from "next-intl"; import ProxyRegistryManager from "./ProxyRegistryManager"; +import OneproxyTab from "./OneproxyTab"; export default function ProxyTab() { const [proxyModalOpen, setProxyModalOpen] = useState(false); @@ -137,6 +138,7 @@ export default function ProxyTab() { +
diff --git a/src/app/api/settings/oneproxy/rotate/route.ts b/src/app/api/settings/oneproxy/rotate/route.ts new file mode 100644 index 0000000000..f6233062e7 --- /dev/null +++ b/src/app/api/settings/oneproxy/rotate/route.ts @@ -0,0 +1,44 @@ +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { rotateOneproxyProxy } from "@/lib/oneproxyRotator"; +import { oneproxyRotateSchema } from "@/shared/validation/oneproxySchemas"; + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + rawBody = {}; + } + + try { + const validation = validateBody(oneproxyRotateSchema, rawBody); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + type: "invalid_request", + }); + } + + const proxy = await rotateOneproxyProxy({ + strategy: validation.data.strategy, + }); + + if (!proxy) { + return createErrorResponse({ + status: 404, + message: "No active 1proxy proxies available", + type: "not_found", + }); + } + + return Response.json(proxy); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to rotate 1proxy proxy"); + } +} diff --git a/src/app/api/settings/oneproxy/route.ts b/src/app/api/settings/oneproxy/route.ts new file mode 100644 index 0000000000..a1d938022c --- /dev/null +++ b/src/app/api/settings/oneproxy/route.ts @@ -0,0 +1,126 @@ +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { + listOneproxyProxies, + getOneproxyStats, + deleteOneproxyProxy, + clearAllOneproxyProxies, +} from "@/lib/localDb"; +import { + syncOneproxyProxies, + getOneproxySyncStatus, + resetOneproxyCircuitBreaker, +} from "@/lib/oneproxySync"; +import { oneproxyFilterSchema, oneproxySyncSchema } from "@/shared/validation/oneproxySchemas"; + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { searchParams } = new URL(request.url); + const action = searchParams.get("action"); + + if (action === "stats") { + const stats = await getOneproxyStats(); + const status = getOneproxySyncStatus(); + return Response.json({ stats, status }); + } + + if (action === "status") { + return Response.json(getOneproxySyncStatus()); + } + + const filterValidation = validateBody(oneproxyFilterSchema, { + protocol: searchParams.get("protocol") || undefined, + countryCode: searchParams.get("countryCode") || undefined, + minQuality: searchParams.get("minQuality") || undefined, + limit: searchParams.get("limit") || undefined, + }); + + if (isValidationFailure(filterValidation)) { + return createErrorResponse({ + status: 400, + message: filterValidation.error.message, + type: "invalid_request", + }); + } + + const proxies = await listOneproxyProxies({ + protocol: filterValidation.data.protocol, + countryCode: filterValidation.data.countryCode, + minQuality: filterValidation.data.minQuality, + limit: filterValidation.data.limit, + }); + + return Response.json({ items: proxies, total: proxies.length }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to load 1proxy proxies"); + } +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return createErrorResponse({ + status: 400, + message: "Invalid JSON body", + type: "invalid_request", + }); + } + + try { + const validation = validateBody(oneproxySyncSchema, rawBody); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + type: "invalid_request", + }); + } + + const result = await syncOneproxyProxies(); + return Response.json(result); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to sync 1proxy proxies"); + } +} + +export async function DELETE(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + const clearAll = searchParams.get("clearAll") === "1"; + + if (clearAll) { + const count = await clearAllOneproxyProxies(); + return Response.json({ success: true, deleted: count }); + } + + if (!id) { + return createErrorResponse({ + status: 400, + message: "id or clearAll is required", + type: "invalid_request", + }); + } + + const deleted = await deleteOneproxyProxy(id); + if (!deleted) { + return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); + } + + return Response.json({ success: true }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to delete 1proxy proxy"); + } +} diff --git a/src/lib/db/migrations/040_oneproxy_proxy_fields.sql b/src/lib/db/migrations/040_oneproxy_proxy_fields.sql new file mode 100644 index 0000000000..464b9ca242 --- /dev/null +++ b/src/lib/db/migrations/040_oneproxy_proxy_fields.sql @@ -0,0 +1,24 @@ +-- 040_oneproxy_proxy_fields.sql +-- Add 1proxy-specific columns to proxy_registry to support free proxy +-- marketplace integration (Issue #1788). +-- +-- New columns: +-- source — 'manual' or 'oneproxy' (distinguishes origin) +-- quality_score — 0-100 quality rating from 1proxy validation +-- latency_ms — measured latency in milliseconds +-- anonymity — transparent, anonymous, or elite +-- google_access — whether proxy can access Google (0/1) +-- last_validated — ISO timestamp of last validation +-- country_code — two-letter ISO country code + +ALTER TABLE proxy_registry ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'; +ALTER TABLE proxy_registry ADD COLUMN quality_score INTEGER; +ALTER TABLE proxy_registry ADD COLUMN latency_ms INTEGER; +ALTER TABLE proxy_registry ADD COLUMN anonymity TEXT; +ALTER TABLE proxy_registry ADD COLUMN google_access INTEGER DEFAULT 0; +ALTER TABLE proxy_registry ADD COLUMN last_validated TEXT; +ALTER TABLE proxy_registry ADD COLUMN country_code TEXT; + +CREATE INDEX IF NOT EXISTS idx_proxy_registry_source ON proxy_registry(source); +CREATE INDEX IF NOT EXISTS idx_proxy_registry_quality ON proxy_registry(quality_score DESC); +CREATE INDEX IF NOT EXISTS idx_proxy_registry_country ON proxy_registry(country_code); diff --git a/src/lib/db/oneproxy.ts b/src/lib/db/oneproxy.ts new file mode 100644 index 0000000000..900c58c08f --- /dev/null +++ b/src/lib/db/oneproxy.ts @@ -0,0 +1,293 @@ +import { randomUUID } from "crypto"; +import { getDbInstance } from "./core"; +import { backupDbFile } from "./backup"; + +type JsonRecord = Record; + +export interface OneproxyProxyRecord { + id: string; + name: string; + type: string; + host: string; + port: number; + region: string | null; + notes: string | null; + status: string; + source: string; + qualityScore: number | null; + latencyMs: number | null; + anonymity: string | null; + googleAccess: boolean; + lastValidated: string | null; + countryCode: string | null; + createdAt: string; + updatedAt: string; +} + +export interface OneproxyStats { + total: number; + active: number; + avgQuality: number | null; + lastValidated: string | null; + byProtocol: Array<{ protocol: string; count: number }>; + byCountry: Array<{ countryCode: string; count: number }>; +} + +interface OneproxyUpsertInput { + ip: string; + port: number; + protocol: string; + country?: string | null; + countryCode?: string | null; + anonymity?: string | null; + qualityScore?: number | null; + latencyMs?: number | null; + googleAccess?: boolean; + lastValidated?: string | null; +} + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" ? (value as JsonRecord) : {}; +} + +function mapProxyRow(row: unknown): OneproxyProxyRecord { + const r = toRecord(row); + return { + id: typeof r.id === "string" ? r.id : "", + name: typeof r.name === "string" ? r.name : "", + type: typeof r.type === "string" ? r.type : "http", + host: typeof r.host === "string" ? r.host : "", + port: Number(r.port) || 0, + region: typeof r.region === "string" ? r.region : null, + notes: typeof r.notes === "string" ? r.notes : null, + status: typeof r.status === "string" ? r.status : "active", + source: typeof r.source === "string" ? r.source : "oneproxy", + qualityScore: typeof r.quality_score === "number" ? r.quality_score : null, + latencyMs: typeof r.latency_ms === "number" ? r.latency_ms : null, + anonymity: typeof r.anonymity === "string" ? r.anonymity : null, + googleAccess: r.google_access === 1 || r.google_access === true, + lastValidated: typeof r.last_validated === "string" ? r.last_validated : null, + countryCode: typeof r.country_code === "string" ? r.country_code : null, + createdAt: typeof r.created_at === "string" ? r.created_at : "", + updatedAt: typeof r.updated_at === "string" ? r.updated_at : "", + }; +} + +function mapStatsRow(row: unknown) { + const r = toRecord(row); + return { + total: Number(r.total) || 0, + active: Number(r.active) || 0, + avgQuality: + r.avg_quality !== null && r.avg_quality !== undefined + ? Math.round(Number(r.avg_quality) * 100) / 100 + : null, + lastValidated: typeof r.last_validated === "string" ? r.last_validated : null, + }; +} + +export async function listOneproxyProxies(options?: { + protocol?: string; + countryCode?: string; + minQuality?: number; + limit?: number; +}): Promise { + const db = getDbInstance(); + + let sql = "SELECT * FROM proxy_registry WHERE source = 'oneproxy' AND status = 'active'"; + const params: unknown[] = []; + + if (options?.protocol) { + sql += " AND type = ?"; + params.push(options.protocol); + } + if (options?.countryCode) { + sql += " AND country_code = ?"; + params.push(options.countryCode); + } + if (options?.minQuality != null) { + sql += " AND quality_score >= ?"; + params.push(options.minQuality); + } + + sql += " ORDER BY quality_score DESC, last_validated DESC"; + + if (options?.limit) { + sql += " LIMIT ?"; + params.push(options.limit); + } + + const rows = db.prepare(sql).all(...params); + return rows.map(mapProxyRow); +} + +export async function getOneproxyStats(): Promise { + const db = getDbInstance(); + + const statsRow = db + .prepare( + `SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, + AVG(quality_score) as avg_quality, + MAX(last_validated) as last_validated + FROM proxy_registry WHERE source = 'oneproxy'` + ) + .get(); + + const stats = mapStatsRow(statsRow); + + const byProtocol = db + .prepare( + "SELECT type as protocol, COUNT(*) as count FROM proxy_registry WHERE source = 'oneproxy' GROUP BY type ORDER BY count DESC" + ) + .all() as Array; + + const byCountry = db + .prepare( + "SELECT country_code as countryCode, COUNT(*) as count FROM proxy_registry WHERE source = 'oneproxy' AND country_code IS NOT NULL GROUP BY country_code ORDER BY count DESC LIMIT 20" + ) + .all() as Array; + + return { + ...stats, + byProtocol: byProtocol.map((r) => ({ + protocol: String(r.protocol || "unknown"), + count: Number(r.count) || 0, + })), + byCountry: byCountry.map((r) => ({ + countryCode: String(r.countryCode || "unknown"), + count: Number(r.count) || 0, + })), + }; +} + +export async function upsertOneproxyProxy( + input: OneproxyUpsertInput +): Promise<{ proxy: OneproxyProxyRecord | null; action: "created" | "updated" }> { + const db = getDbInstance(); + const now = new Date().toISOString(); + + const name = `${input.protocol?.toUpperCase() || "HTTP"} - ${input.countryCode || "Unknown"} - ${input.ip}`; + + const existing = db + .prepare("SELECT id FROM proxy_registry WHERE host = ? AND port = ? AND source = 'oneproxy'") + .get(input.ip, input.port) as { id?: string } | undefined; + + if (existing?.id) { + db.prepare( + `UPDATE proxy_registry + SET status = ?, quality_score = ?, latency_ms = ?, anonymity = ?, + google_access = ?, last_validated = ?, country_code = ?, updated_at = ? + WHERE id = ?` + ).run( + "active", + input.qualityScore ?? null, + input.latencyMs ?? null, + input.anonymity ?? null, + input.googleAccess ? 1 : 0, + input.lastValidated ?? now, + input.countryCode ?? null, + now, + existing.id + ); + backupDbFile("pre-write"); + const proxy = await getOneproxyProxyById(existing.id); + return { proxy, action: "updated" }; + } + + const id = randomUUID(); + db.prepare( + `INSERT INTO proxy_registry + (id, name, type, host, port, region, notes, status, source, + quality_score, latency_ms, anonymity, google_access, last_validated, country_code, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + name, + input.protocol || "http", + input.ip, + input.port, + input.countryCode ?? null, + null, + "active", + "oneproxy", + input.qualityScore ?? null, + input.latencyMs ?? null, + input.anonymity ?? null, + input.googleAccess ? 1 : 0, + input.lastValidated ?? now, + input.countryCode ?? null, + now, + now + ); + backupDbFile("pre-write"); + const proxy = await getOneproxyProxyById(id); + return { proxy, action: "created" }; +} + +export async function getOneproxyProxyById(id: string): Promise { + const db = getDbInstance(); + const row = db + .prepare("SELECT * FROM proxy_registry WHERE id = ? AND source = 'oneproxy'") + .get(id); + if (!row) return null; + return mapProxyRow(row); +} + +export async function deleteOneproxyProxy(id: string): Promise { + const db = getDbInstance(); + const result = db + .prepare("DELETE FROM proxy_registry WHERE id = ? AND source = 'oneproxy'") + .run(id); + backupDbFile("pre-write"); + return result.changes > 0; +} + +export async function clearAllOneproxyProxies(): Promise { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM proxy_registry WHERE source = 'oneproxy'").run(); + backupDbFile("pre-write"); + return result.changes; +} + +export async function getOneproxyProxyForRotation(options?: { + strategy?: "random" | "quality" | "sequential"; +}): Promise { + const db = getDbInstance(); + const strategy = options?.strategy || "quality"; + + let sql = "SELECT * FROM proxy_registry WHERE source = 'oneproxy' AND status = 'active'"; + + switch (strategy) { + case "quality": + sql += " ORDER BY quality_score DESC, latency_ms ASC LIMIT 1"; + break; + case "random": + sql += " ORDER BY RANDOM() LIMIT 1"; + break; + case "sequential": + sql += " ORDER BY last_validated ASC LIMIT 1"; + break; + } + + const row = db.prepare(sql).get(); + if (!row) return null; + return mapProxyRow(row); +} + +export async function markOneproxyProxyFailed(host: string, port: number): Promise { + const db = getDbInstance(); + const result = db + .prepare( + `UPDATE proxy_registry + SET quality_score = MAX(0, COALESCE(quality_score, 50) - 10), + status = CASE WHEN COALESCE(quality_score, 50) <= 10 THEN 'inactive' ELSE status END, + updated_at = datetime('now') + WHERE host = ? AND port = ? AND source = 'oneproxy'` + ) + .run(host, port); + backupDbFile("pre-write"); + return result.changes > 0; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 3c44ff16ba..df440bb2a2 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -347,3 +347,17 @@ export { } from "./db/reasoningCache"; export type { ReasoningCacheEntry, ReasoningCacheStats } from "./db/reasoningCache"; + +export { + // 1proxy Integration (#1788) + listOneproxyProxies, + getOneproxyStats, + upsertOneproxyProxy, + getOneproxyProxyById, + deleteOneproxyProxy, + clearAllOneproxyProxies, + getOneproxyProxyForRotation, + markOneproxyProxyFailed, +} from "./db/oneproxy"; + +export type { OneproxyProxyRecord, OneproxyStats } from "./db/oneproxy"; diff --git a/src/lib/oneproxyRotator.ts b/src/lib/oneproxyRotator.ts new file mode 100644 index 0000000000..e79c4cf14a --- /dev/null +++ b/src/lib/oneproxyRotator.ts @@ -0,0 +1,19 @@ +import { getOneproxyProxyForRotation, markOneproxyProxyFailed } from "./db/oneproxy"; +import type { OneproxyProxyRecord } from "./db/oneproxy"; + +let sequentialIndex = 0; + +export async function rotateOneproxyProxy(options?: { + strategy?: "random" | "quality" | "sequential"; +}): Promise { + const strategy = options?.strategy || "quality"; + return getOneproxyProxyForRotation({ strategy }); +} + +export async function failOneproxyProxy(host: string, port: number): Promise { + return markOneproxyProxyFailed(host, port); +} + +export function resetSequentialIndex(): void { + sequentialIndex = 0; +} diff --git a/src/lib/oneproxySync.ts b/src/lib/oneproxySync.ts new file mode 100644 index 0000000000..62f68767cc --- /dev/null +++ b/src/lib/oneproxySync.ts @@ -0,0 +1,182 @@ +import { upsertOneproxyProxy } from "./db/oneproxy"; +import { getSettings } from "./db/settings"; + +type OneProxyApiResponse = { + total: number; + count: number; + offset: number; + limit: number; + proxies: Array<{ + id: number; + url: string; + protocol: string; + ip: string; + port: number; + country_code: string; + country_name: string; + latency_ms: number; + anonymity: string; + proxy_type: string; + can_access_google: boolean; + quality_score: number; + is_working: boolean; + validation_status: string; + last_validated: string; + }>; +}; + +const DEFAULT_API_URL = "https://1proxy-api.aitradepulse.com"; +const DEFAULT_MAX_PROXIES = 500; +const DEFAULT_MIN_QUALITY = 50; +const DEFAULT_PAGE_SIZE = 100; + +let lastSyncSuccess = false; +let lastSyncError: string | null = null; +let lastSyncAt: string | null = null; +let lastSyncCount = 0; +let consecutiveFailures = 0; +const MAX_CONSECUTIVE_FAILURES = 5; + +export async function syncOneproxyProxies(options?: { + apiUrl?: string; + maxProxies?: number; + minQuality?: number; +}): Promise<{ + success: boolean; + added: number; + updated: number; + failed: number; + total: number; + error: string | null; +}> { + const settings = await getSettings(); + const enabled = process.env.ONEPROXY_ENABLED !== "false"; + if (!enabled) { + return { + success: false, + added: 0, + updated: 0, + failed: 0, + total: 0, + error: "1proxy integration disabled", + }; + } + + const apiUrl = options?.apiUrl || process.env.ONEPROXY_API_URL || DEFAULT_API_URL; + const maxProxies = + (options?.maxProxies ?? parseInt(process.env.ONEPROXY_MAX_PROXIES || "", 10)) || + DEFAULT_MAX_PROXIES; + const minQuality = + (options?.minQuality ?? parseInt(process.env.ONEPROXY_MIN_QUALITY_THRESHOLD || "", 10)) || + DEFAULT_MIN_QUALITY; + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + return { + success: false, + added: 0, + updated: 0, + failed: 0, + total: 0, + error: `Circuit breaker: ${consecutiveFailures} consecutive failures. Reset required.`, + }; + } + + let added = 0; + let updated = 0; + let failed = 0; + let offset = 0; + + try { + while (true) { + const url = `${apiUrl}/api/v1/proxies/advanced?limit=${DEFAULT_PAGE_SIZE}&offset=${offset}&min_quality=${minQuality}&is_working=true`; + const response = await fetch(url, { + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`1proxy API returned ${response.status}`); + } + + const data = (await response.json()) as OneProxyApiResponse; + if (!data.proxies || data.proxies.length === 0) break; + + for (const proxy of data.proxies) { + if (added + updated >= maxProxies) break; + + try { + const result = await upsertOneproxyProxy({ + ip: proxy.ip, + port: proxy.port, + protocol: proxy.protocol, + countryCode: proxy.country_code, + anonymity: proxy.anonymity, + qualityScore: proxy.quality_score, + latencyMs: proxy.latency_ms, + googleAccess: proxy.can_access_google, + lastValidated: proxy.last_validated, + }); + + if (result.action === "created") added++; + else updated++; + } catch { + failed++; + } + } + + if (added + updated >= maxProxies) break; + if (data.proxies.length < DEFAULT_PAGE_SIZE) break; + + offset += DEFAULT_PAGE_SIZE; + } + + consecutiveFailures = 0; + lastSyncSuccess = true; + lastSyncError = null; + lastSyncAt = new Date().toISOString(); + lastSyncCount = added + updated; + + return { + success: true, + added, + updated, + failed, + total: added + updated, + error: null, + }; + } catch (err) { + consecutiveFailures++; + lastSyncSuccess = false; + lastSyncError = err instanceof Error ? err.message : String(err); + lastSyncAt = new Date().toISOString(); + + return { + success: false, + added: 0, + updated: 0, + failed: 0, + total: 0, + error: lastSyncError, + }; + } +} + +export function getOneproxySyncStatus(): { + lastSyncSuccess: boolean; + lastSyncError: string | null; + lastSyncAt: string | null; + lastSyncCount: number; + consecutiveFailures: number; +} { + return { + lastSyncSuccess, + lastSyncError, + lastSyncAt, + lastSyncCount, + consecutiveFailures, + }; +} + +export function resetOneproxyCircuitBreaker(): void { + consecutiveFailures = 0; + lastSyncError = null; +} diff --git a/src/shared/constants/mcpScopes.ts b/src/shared/constants/mcpScopes.ts index 1dcff9fcd4..4eb4a34468 100644 --- a/src/shared/constants/mcpScopes.ts +++ b/src/shared/constants/mcpScopes.ts @@ -24,6 +24,7 @@ export const MCP_SCOPE_LIST = [ "write:cache", "read:compression", "write:compression", + "read:proxies", ] as const; export type McpScope = (typeof MCP_SCOPE_LIST)[number]; @@ -58,6 +59,9 @@ export const MCP_TOOL_SCOPES: Record = { omniroute_cache_flush: ["write:cache"], omniroute_compression_status: ["read:compression"], omniroute_compression_configure: ["write:compression"], + omniroute_oneproxy_fetch: ["read:proxies"], + omniroute_oneproxy_rotate: ["read:proxies"], + omniroute_oneproxy_stats: ["read:proxies"], } as const; // ============ Scope Groups ============ diff --git a/src/shared/validation/oneproxySchemas.ts b/src/shared/validation/oneproxySchemas.ts new file mode 100644 index 0000000000..89ed71eb52 --- /dev/null +++ b/src/shared/validation/oneproxySchemas.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const oneproxyFilterSchema = z.object({ + protocol: z.enum(["http", "https", "socks4", "socks5"]).optional(), + countryCode: z.string().max(2).optional(), + minQuality: z.coerce.number().int().min(0).max(100).optional(), + maxProxies: z.coerce.number().int().min(1).max(1000).optional(), +}); + +export const oneproxySyncSchema = z.object({}).strict(); + +export const oneproxyRotateSchema = z.object({ + strategy: z.enum(["random", "quality", "sequential"]).optional(), +});