diff --git a/docs/reference/FREE_PROXIES_API.md b/docs/reference/FREE_PROXIES_API.md new file mode 100644 index 0000000000..0932297c6d --- /dev/null +++ b/docs/reference/FREE_PROXIES_API.md @@ -0,0 +1,77 @@ +--- +title: "Free Proxies API" +version: 3.8.43 +lastUpdated: 2026-07-11 +--- + +# Free Proxies API + +OmniRoute ships a curated pool of free proxies in the `free_proxies` table, +synced from external providers (1proxy, proxifly, iplocate, webshare). The +dashboard surfaces these under **Settings → Free Proxies**. This document +covers the server-side filtering, sorting, counting, and sync-error reporting +that the list route exposes. + +## List route — `GET /api/settings/free-proxies` + +Returns a filtered, sorted, paginated slice plus a total count. Filtering and +counting happen in SQL, so the UI can show the real total (e.g. `Total: 0`) +without loading every row into memory. + +### Query parameters + +| Param | Type | Default | Meaning | +| ----------------- | ---------------------------------- | --------- | ---------------------------------------------------------------------------------------------- | +| `search` | string | `""` | Case-sensitive `LIKE` on the host (and source) column. | +| `protocol` | string | `""` | `type` filter: `http` / `https` / `socks4` / `socks5`. Empty = all. | +| `country` | string | `""` | `countryCode` filter (ISO-2). Empty = all. | +| `minQuality` | number | `0` | Only rows with `qualityScore >= minQuality`. `0` = no floor. | +| `disabledSources` | string | `""` | Comma-separated source ids to exclude (e.g. `proxifly,webshare`). | +| `sortBy` | `quality` \| `latency` \| `recent` | `quality` | `quality` = score desc; `latency` = latency asc (nulls last); `recent` = `lastValidated` desc. | +| `offset` | number | `0` | Pagination start. | +| `limit` | number | `50` | Page size (capped server-side). | + +### Response + +```json +{ + "success": true, + "data": { + "proxies": [/* FreeProxyRecord[] */], + "total": 137, + "hasMore": true + }, + "stats": { + "total": 137, + "inPool": 12, + "avgQuality": 64.2, + "bySource": [{ "source": "1proxy", "count": 90 }], + "lastSyncAt": "2026-07-11T09:30:00.000Z" + }, + "syncErrors": { + "proxifly": ["HTTP 429 from upstream"], + "webshare": ["network timeout"] + } +} +``` + +`total` reflects the filtered total **before** pagination, so the UI can render +`Total: N` and `hasMore` independently. `syncErrors` is keyed by source id and +populated only for sources that failed their last sync — a `Total: 0` result is +never silent. + +## Add-to-pool — `POST /api/settings/free-proxies/[id]/add-to-pool` + +Promotes a free proxy into the managed `proxy_registry` pool. Validates the +upstream first; on success returns the new pool proxy id and measured latency. + +## Sync — `POST /api/settings/free-proxies/sync` + +Re-pulls all enabled sources (or the subset in `{ "sources": [...] }`). Each +source syncs independently; a failing source is recorded in `syncErrors` and the +others still complete, so partial syncs never wipe prior good data. + +## Stats — `GET /api/settings/free-proxies/stats` + +Returns the `total / inPool / avgQuality / bySource / lastSyncAt` aggregate +without the row payload — used by the dashboard header widgets. diff --git a/docs/reference/RELAY_TROUBLESHOOTING.md b/docs/reference/RELAY_TROUBLESHOOTING.md new file mode 100644 index 0000000000..f510a24a66 --- /dev/null +++ b/docs/reference/RELAY_TROUBLESHOOTING.md @@ -0,0 +1,93 @@ +--- +title: "Relay Troubleshooting" +version: 3.8.43 +lastUpdated: 2026-07-11 +--- + +# Relay Troubleshooting + +Relays (Vercel, Deno, Cloudflare) terminate the upstream connection on a +serverless backend so OmniRoute can egress from a stable region while keeping +the provider API key server-side. This document covers the two failure modes +that operators hit in production and the recovery paths OmniRoute ships for +each. + +## How relay auth is stored + +When you deploy a relay from **Settings → Proxy Pool → Deploy Relay**, the +deploy flow stores the relay's auth token in the proxy `notes` JSON field: + +- If `STORAGE_ENCRYPTION_KEY` is set, the token is written as `relayAuthEnc` + (AES-encrypted at rest). +- Otherwise it is written as plaintext `relayAuth`. + +At request time `extractRelayAuth(notes)` returns whichever form is present, +so the relay keeps working across restarts. + +## Failure mode 1 — undecryptable token after key rotation + +**Symptom:** relays that previously worked now return upstream `401`/auth +errors after an environment or secret-manager rotation of +`STORAGE_ENCRYPTION_KEY`. The stored `relayAuthEnc` blob can no longer be +decrypted, so `extractRelayAuth` returns the empty string and the relay sends +no auth. + +**Recovery — repair in place (no redeploy):** + +1. Open **Settings → Proxy Pool**. +2. Relay rows whose auth is missing show a yellow `auth missing` badge and a + **Repair** button. +3. Click **Repair**. OmniRoute calls + `POST /api/settings/proxies/[id]/repair-relay`, which: + - decrypts `relayAuthEnc` with the **current** key, + - writes the plaintext `relayAuth` back into `notes`, + - returns `{ repaired: true, mode: "recovered" }`. + +The relay resumes serving without re-entering any deploy credentials. + +This only works if the current `STORAGE_ENCRYPTION_KEY` can still decrypt the +blob. If you rotated the key **without** a migration, the blob is +unrecoverable. + +## Failure mode 2 — unrecoverable token (key rotated away) + +**Symptom:** you clicked **Repair** and got +`{ repaired: false, mode: "redeploy", status: 409 }`. + +**Recovery — redeploy:** + +The stored token cannot be recovered with the current key. Re-deploy the relay +from the same modal you used originally (**Deploy Relay** menu → Vercel / Deno +/ Cloudflare). The deploy flow writes a fresh token (encrypted with the current +key) and the row's `auth missing` badge clears. + +In the UI, the **Repair** button itself triggers the redeploy modal when the +token is unrecoverable, so you never have to hunt for it manually. + +## Failure mode 3 — relay reachable but unhealthy + +**Symptom:** the proxy test (`speed` button) shows the relay up but requests +still fail intermittently. + +Check the relay awareness headers returned by OmniRoute's auto-test probe +(see **Settings → Proxy Pool** and the `relayTested` / `relayAlive` counters): + +- `x-relay-url` — which relay backend answered. +- `x-relay-mode` — `ts` | `bifrost` | `auto` for that request. +- `x-relay-attempts` — how many relay hops were tried before success. +- `x-relay-fallback` — `true` when the request fell back from the preferred + backend to the TypeScript relay. + +A high `x-relay-fallback` rate with low `relayAlive` means the sidecar +backend is unhealthy and you should either fix it or switch the relay backend +strategy to `ts` (see `RELAY_BACKEND_STRATEGY.md`). + +## API reference + +| Method | Path | Body | Success | Failure | +| ------ | ----------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `POST` | `/api/settings/proxies/[id]/repair-relay` | `{ "id": "" }` | `200 { repaired: true, mode: "recovered" }` when re-derived; `200 { repaired: false, mode: "noop" }` when plaintext already present | `409 { mode: "redeploy" }` unrecoverable; `400` not a relay type; `404` not found | + +The list route `GET /api/settings/proxies` attaches a secret-free +`relayInfo: { isRelay, authMissing, repairMode }` to each item so the dashboard +can render the repair affordance without ever exposing the token. diff --git a/docs/reference/meta.json b/docs/reference/meta.json index e7d25ec571..83b8179b0f 100644 --- a/docs/reference/meta.json +++ b/docs/reference/meta.json @@ -6,6 +6,7 @@ "ENVIRONMENT", "FEATURE_FLAGS", "FREE_TIERS", + "FREE_PROXIES_API", "PROVIDER_REFERENCE" ] } diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index 191be4cc9d..36a99ddebd 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; +import { z } from "zod"; import { Button, Card, Modal } from "@/shared/components"; import { useProxyBatchOperations } from "./useProxyBatchOperations"; import { ProxyStatusBadge } from "./ProxyStatusBadge"; @@ -9,20 +10,7 @@ import { ProxyHealthCell } from "./ProxyHealthCell"; import { ProxyBatchActions } from "./ProxyBatchActions"; import { ProxyCheckboxCell } from "./ProxyCheckboxCell"; import { POOL_STRATEGY_OPTIONS, isPoolStrategy, type PoolStrategy } from "./proxyStrategyOptions"; - -type ProxyItem = { - id: string; - name: string; - type: string; - host: string; - port: number; - username?: string | null; - password?: string | null; - region?: string | null; - notes?: string | null; - status?: string; - family?: string; -}; +import type { ProxyItem } from "./proxyRegistryTypes"; type UsageInfo = { count: number; @@ -155,7 +143,11 @@ function parseBulkImportText(text: string): { return { entries, errors, skipped }; } -export default function ProxyRegistryManager() { +export default function ProxyRegistryManager({ + onRedeployRelay, +}: { + onRedeployRelay?: (proxy: ProxyItem) => void; +} = {}) { const t = useTranslations("proxyRegistry"); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); @@ -169,6 +161,10 @@ export default function ProxyRegistryManager() { const [healthById, setHealthById] = useState>({}); const [testById, setTestById] = useState>({}); const [testingId, setTestingId] = useState(null); + const [repairingId, setRepairingId] = useState(null); + const [repairErrorById, setRepairErrorById] = useState>({}); + const [relayTested, setRelayTested] = useState(null); + const [relayAlive, setRelayAlive] = useState(null); const [migrating, setMigrating] = useState(false); const [bulkOpen, setBulkOpen] = useState(false); const [bulkSaving, setBulkSaving] = useState(false); @@ -260,6 +256,11 @@ export default function ProxyRegistryManager() { setItems([]); return; } + const stats = data?.relayProbeStats; + if (stats && typeof stats.tested === "number" && typeof stats.alive === "number") { + setRelayTested(stats.tested); + setRelayAlive(stats.alive); + } const loaded: ProxyItem[] = Array.isArray(data?.items) ? data.items : []; setItems(loaded); const ids = loaded.map((p) => p.id).filter(Boolean); @@ -395,6 +396,51 @@ export default function ProxyRegistryManager() { } }; + const repairRelayResponseSchema = z.object({ + repaired: z.boolean().optional(), + mode: z.enum(["noop", "recovered", "redeploy"]).optional(), + error: z.object({ message: z.string() }).optional(), + }); + + const handleRepairRelay = async (item: ProxyItem) => { + if (repairingId || !item.relayInfo?.isRelay) return; + setRepairingId(item.id); + setRepairErrorById((prev) => { + const next = { ...prev }; + delete next[item.id]; + return next; + }); + try { + const res = await fetch(`/api/settings/proxies/${item.id}/repair-relay`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: item.id }), + }); + const parsed = repairRelayResponseSchema.safeParse(await res.json()); + const data = parsed.success ? parsed.data : {}; + if (!res.ok) { + if (res.status === 409 && onRedeployRelay) { + onRedeployRelay(item); + return; + } + const message = + res.status === 409 + ? t("relayRepairRedeployRequired") + : data.error?.message || t("relayRepairFailed"); + setRepairErrorById((prev) => ({ ...prev, [item.id]: message })); + return; + } + if (data.repaired) { + await load(); + } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : t("relayRepairFailed"); + setRepairErrorById((prev) => ({ ...prev, [item.id]: message })); + } finally { + setRepairingId(null); + } + }; + const handleSave = async () => { if (!(form.name || "").trim() || !(form.host || "").trim()) { setError(t("errorNameHostRequired")); @@ -405,6 +451,7 @@ export default function ProxyRegistryManager() { setError(null); const normalizedUsername = (form.username || "").trim(); + const normalizedPassword = (form.password || "").trim(); const payload: Record = { @@ -808,6 +855,11 @@ export default function ProxyRegistryManager() { {error} )} + {relayTested !== null && relayAlive !== null && ( +
+ {t("relayProbeSummary", { tested: relayTested, alive: relayAlive })} +
+ )} {loading ? (
{t("loading")}
@@ -884,6 +936,33 @@ export default function ProxyRegistryManager() { > {t("test")} + {item.relayInfo?.isRelay && + (item.relayInfo.repairMode === "redeploy" || + item.relayInfo.repairMode === "recovered") && ( + + )} + {item.relayInfo?.isRelay && item.relayInfo.authMissing && ( + + {t("relayAuthMissing")} + + )} + {repairErrorById[item.id] && ( + + {t("relayRepairError")} + + )}