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 67d415c8e6..5c4d7a67bc 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"; @@ -10,20 +11,7 @@ import { ProxyBatchActions } from "./ProxyBatchActions"; import { ProxyCheckboxCell } from "./ProxyCheckboxCell"; import { parseBulkImportText, type ParsedProxyEntry, type ParseError } from "./parseBulkProxyImport"; 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; @@ -103,7 +91,11 @@ const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import #`; -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); @@ -117,6 +109,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); @@ -133,22 +129,6 @@ export default function ProxyRegistryManager() { const [poolMembers, setPoolMembers] = useState([]); const [poolAddProxyId, setPoolAddProxyId] = useState(""); const [poolLoading, setPoolLoading] = useState(false); - const [poolSaving, setPoolSaving] = useState(false); - const [poolLoaded, setPoolLoaded] = useState(false); - - // Bulk Import state - const [bulkImportOpen, setBulkImportOpen] = useState(false); - const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE); - const [bulkImportParsed, setBulkImportParsed] = useState([]); - const [bulkImportErrors, setBulkImportErrors] = useState([]); - const [bulkImportSkipped, setBulkImportSkipped] = useState(0); - const [bulkImportParsedOnce, setBulkImportParsedOnce] = useState(false); - const [bulkImporting, setBulkImporting] = useState(false); - const [bulkImportResult, setBulkImportResult] = useState<{ - created: number; - updated: number; - failed: number; - } | null>(null); const editingId = useMemo(() => form.id || "", [form.id]); @@ -208,6 +188,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); @@ -343,6 +328,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")); @@ -353,6 +383,7 @@ export default function ProxyRegistryManager() { setError(null); const normalizedUsername = (form.username || "").trim(); + const normalizedPassword = (form.password || "").trim(); const payload: Record = { @@ -756,6 +787,11 @@ export default function ProxyRegistryManager() { {error} )} + {relayTested !== null && relayAlive !== null && ( +
+ {t("relayProbeSummary", { tested: relayTested, alive: relayAlive })} +
+ )} {loading ? (
{t("loading")}
@@ -832,6 +868,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")} + + )} + {buildPageRange().map((p, i) => + p === "ellipsis" ? ( + + ... + + ) : ( + + ) + )} + + + )} + + {/* Per-page summary */} +
+ {total > 0 + ? `Page ${page} of ${totalPages} (${total} total proxies)` + : `${total} total proxies`} +
); } diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx index e31b8b1dd3..52406cfd22 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx @@ -6,6 +6,7 @@ import ProxyRegistryManager from "../ProxyRegistryManager"; import VercelRelayModal from "./VercelRelayModal"; import DenoRelayModal from "./DenoRelayModal"; import CloudflareRelayModal from "./CloudflareRelayModal"; +import type { ProxyItem } from "../proxyRegistryTypes"; export default function ProxyPoolTab() { const t = useTranslations("settings"); @@ -41,6 +42,11 @@ export default function ProxyPoolTab() { const handleCloudflareDeployed = (_poolProxyId: string, relayUrl: string) => { alert(`${t("cloudflareRelaySuccess")}: ${relayUrl}`); }; + const handleRedeployRelay = (proxy: ProxyItem) => { + if (proxy.type === "vercel") setVercelModalOpen(true); + else if (proxy.type === "deno") setDenoModalOpen(true); + else if (proxy.type === "cloudflare") setCloudflareModalOpen(true); + }; return (
@@ -116,7 +122,7 @@ export default function ProxyPoolTab() {
)} - + setVercelModalOpen(false)} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxyRegistryTypes.ts b/src/app/(dashboard)/dashboard/settings/components/proxyRegistryTypes.ts new file mode 100644 index 0000000000..787c1b5240 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxyRegistryTypes.ts @@ -0,0 +1,29 @@ +// Client-safe types for the proxy registry UI. This module MUST NOT import +// server-only code (e.g. `lib/db/proxies/mappers`, which pulls in node `crypto` +// via the encryption helper) — it is imported by "use client" components. +// +// `RelayRepairMode` mirrors the server-side union returned by +// `relayRepairMode()` in lib/db/proxies/mappers.ts. Keep the two in sync. + +export type RelayRepairMode = "noop" | "recovered" | "redeploy" | null; + +export interface RelayInfo { + isRelay: boolean; + authMissing: boolean; + repairMode: RelayRepairMode; +} + +export interface 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; + relayInfo?: RelayInfo; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/useProxyBulkImport.ts b/src/app/(dashboard)/dashboard/settings/components/useProxyBulkImport.ts new file mode 100644 index 0000000000..a152b8d7dc --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/useProxyBulkImport.ts @@ -0,0 +1,176 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; + +type ParsedProxyEntry = { + name: string; + host: string; + port: number; + username: string; + password: string; + type: string; + region: string; + status: string; + notes: string; +}; + +type ParseError = { + line: number; + reason: string; +}; + +type BulkImportResult = { + created: number; + updated: number; + failed: number; +}; + +const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import +# Format: NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES +# Required: NAME, HOST, PORT +# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES +# Lines starting with # are ignored. Existing proxies (same host+port) will be updated. +# +# SOCKS5 examples: +# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy +# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West +# +# HTTP/HTTPS examples: +# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy +# https-proxy|proxy.example.com|443|admin|secret123|https|US|active +`; + +const VALID_TYPES = new Set(["http", "https", "socks5"]); +const VALID_STATUSES = new Set(["active", "inactive"]); + +export function parseBulkImportText(text: string): { + entries: ParsedProxyEntry[]; + errors: ParseError[]; + skipped: number; +} { + const lines = text.split("\n"); + const entries: ParsedProxyEntry[] = []; + const errors: ParseError[] = []; + let skipped = 0; + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i].trim(); + if (!raw || raw.startsWith("#")) { + skipped++; + continue; + } + + const parts = raw.split("|").map((p) => p.trim()); + const [name, host, portStr, username, password, type, region, status, notes] = parts; + const lineNum = i + 1; + + if (!name) { + errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" }); + continue; + } + if (!host) { + errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" }); + continue; + } + const port = Number(portStr); + if (!portStr || isNaN(port) || port < 1 || port > 65535) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" }); + continue; + } + const normalizedType = (type || "socks5").toLowerCase(); + if (!VALID_TYPES.has(normalizedType)) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" }); + continue; + } + const normalizedStatus = (status || "active").toLowerCase(); + if (!VALID_STATUSES.has(normalizedStatus)) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" }); + continue; + } + + entries.push({ + name, + host, + port, + username: username || "", + password: password || "", + type: normalizedType, + region: region || "", + status: normalizedStatus, + notes: notes || "", + }); + } + + return { entries, errors, skipped }; +} + +interface UseProxyBulkImportOptions { + onImport: (entries: ParsedProxyEntry[]) => Promise; +} + +export function useProxyBulkImport({ onImport }: UseProxyBulkImportOptions) { + const t = useTranslations("proxyRegistry"); + const [open, setOpen] = useState(false); + const [text, setText] = useState(BULK_IMPORT_TEMPLATE); + const [parsed, setParsed] = useState([]); + const [errors, setErrors] = useState([]); + const [skipped, setSkipped] = useState(0); + const [parsedOnce, setParsedOnce] = useState(false); + const [importing, setImporting] = useState(false); + const [result, setResult] = useState(null); + + const handleParse = useCallback(() => { + const { entries, errors: parseErrors, skipped: skippedLines } = parseBulkImportText(text); + setParsed(entries); + setErrors(parseErrors); + setSkipped(skippedLines); + setParsedOnce(true); + setResult(null); + }, [text]); + + const handleImport = useCallback(async () => { + if (parsed.length === 0) return; + setImporting(true); + try { + const res = await onImport(parsed); + setResult(res); + if (res.failed === 0) { + setText(BULK_IMPORT_TEMPLATE); + setParsed([]); + setErrors([]); + setSkipped(0); + setParsedOnce(false); + } + } finally { + setImporting(false); + } + }, [parsed, onImport]); + + const handleClose = useCallback(() => { + setOpen(false); + setText(BULK_IMPORT_TEMPLATE); + setParsed([]); + setErrors([]); + setSkipped(0); + setParsedOnce(false); + setResult(null); + }, []); + + return { + open, + setOpen, + text, + setText, + parsed, + errors, + skipped, + parsedOnce, + importing, + result, + t, + handleParse, + handleImport, + handleClose, + }; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/useProxyPoolModal.ts b/src/app/(dashboard)/dashboard/settings/components/useProxyPoolModal.ts new file mode 100644 index 0000000000..b23e48d6cb --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/useProxyPoolModal.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; + +interface UseProxyPoolModalOptions { + items: { id: string; name: string }[]; + onSave: ( + scope: string, + scopeIds: string, + proxyId: string, + strategy: "round-robin" | "random" | "sticky" + ) => Promise; + onLoad: ( + scope: string, + scopeIds: string, + proxyId: string + ) => Promise<{ members: string[]; strategy: string } | null>; +} + +export function useProxyPoolModal({ items, onSave, onLoad }: UseProxyPoolModalOptions) { + const t = useTranslations("proxyRegistry"); + const [open, setOpen] = useState(false); + const [scope, setScope] = useState<"global" | "provider">("provider"); + const [scopeIds, setScopeIds] = useState(""); + const [proxyId, setProxyId] = useState(""); + const [strategy, setStrategy] = useState<"round-robin" | "random" | "sticky">("round-robin"); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loaded, setLoaded] = useState(false); + + const handleLoad = useCallback(async () => { + if (!proxyId) return; + setLoading(true); + setError(null); + try { + const data = await onLoad(scope, scopeIds, proxyId); + if (data) { + setMembers(data.members); + setStrategy(data.strategy as "round-robin" | "random" | "sticky"); + setLoaded(true); + } + } catch (e: any) { + setError(e?.message || t("poolLoadFailed")); + } finally { + setLoading(false); + } + }, [scope, scopeIds, proxyId, onLoad, t]); + + const handleSave = useCallback(async () => { + if (!proxyId) return; + setLoading(true); + setError(null); + try { + await onSave(scope, scopeIds, proxyId, strategy); + setOpen(false); + } catch (e: any) { + setError(e?.message || t("poolSaveFailed")); + } finally { + setLoading(false); + } + }, [scope, scopeIds, proxyId, strategy, onSave, t]); + + const handleClose = useCallback(() => { + setOpen(false); + setScope("provider"); + setScopeIds(""); + setProxyId(""); + setStrategy("round-robin"); + setMembers([]); + setLoaded(false); + setError(null); + }, []); + + return { + open, + setOpen, + scope, + setScope, + scopeIds, + setScopeIds, + proxyId, + setProxyId, + strategy, + setStrategy, + members, + loading, + error, + loaded, + handleLoad, + handleSave, + handleClose, + t, + }; +} diff --git a/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts b/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts index f1590b1da6..2cfb6ddefa 100644 --- a/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts +++ b/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts @@ -104,7 +104,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return createErrorResponse({ status: 500, message: "Failed to create proxy in registry", - type: "internal_error", + type: "server_error", }); } diff --git a/src/app/api/settings/free-proxies/route.ts b/src/app/api/settings/free-proxies/route.ts index efa58a5051..a36132535f 100644 --- a/src/app/api/settings/free-proxies/route.ts +++ b/src/app/api/settings/free-proxies/route.ts @@ -2,7 +2,14 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { freeProxyListSchema, freeProxySourceSchema } from "@/shared/validation/freeProxySchemas"; -import { listFreeProxies, deleteFreeProxy, clearFreeProxiesBySource } from "@/lib/localDb"; +import { + listFreeProxies, + countFreeProxies, + deleteFreeProxy, + clearFreeProxiesBySource, + getFreeProxyStats, + getFreeProxySyncErrors, +} from "@/lib/localDb"; import type { FreeProxySourceId } from "@/lib/freeProxyProviders/types"; export async function GET(request: Request) { @@ -16,6 +23,8 @@ export async function GET(request: Request) { protocol: searchParams.get("protocol") || undefined, country: searchParams.get("country") || undefined, minQuality: searchParams.get("minQuality") || undefined, + search: searchParams.get("search") || undefined, + sortBy: searchParams.get("sortBy") || undefined, limit: searchParams.get("limit") || undefined, offset: searchParams.get("offset") || undefined, onlyNotInPool: searchParams.get("onlyNotInPool") || undefined, @@ -35,12 +44,36 @@ export async function GET(request: Request) { protocol: validation.data.protocol, country: validation.data.country, minQuality: validation.data.minQuality, + search: validation.data.search, + sortBy: validation.data.sortBy, limit: validation.data.limit, offset: validation.data.offset, onlyNotInPool: validation.data.onlyNotInPool || undefined, }); - return Response.json({ items, total: items.length }); + const total = await countFreeProxies({ + sources: validation.data.sources as FreeProxySourceId[] | undefined, + protocol: validation.data.protocol, + country: validation.data.country, + minQuality: validation.data.minQuality, + search: validation.data.search, + onlyNotInPool: validation.data.onlyNotInPool || undefined, + }); + + const limit = validation.data.limit ?? 50; + const stats = await getFreeProxyStats(); + const syncErrors = await getFreeProxySyncErrors(); + + return Response.json({ + success: true, + data: { + proxies: items, + total, + hasMore: items.length >= limit && total > items.length + (validation.data.offset ?? 0), + stats, + syncErrors, + }, + }); } catch (error) { return createErrorResponseFromUnknown(error, "Failed to list free proxies"); } diff --git a/src/app/api/settings/free-proxies/sync/route.ts b/src/app/api/settings/free-proxies/sync/route.ts index df5bf78e49..94dbaf7698 100644 --- a/src/app/api/settings/free-proxies/sync/route.ts +++ b/src/app/api/settings/free-proxies/sync/route.ts @@ -3,7 +3,11 @@ import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/e import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { freeProxySyncSchema } from "@/shared/validation/freeProxySchemas"; import { getEnabledProviders, getProvider } from "@/lib/freeProxyProviders"; -import { recordFreeProxySync } from "@/lib/localDb"; +import { + recordFreeProxySync, + clearFreeProxySyncErrors, + recordFreeProxySyncErrors, +} from "@/lib/localDb"; import type { FreeProxyProvider, FreeProxySourceId } from "@/lib/freeProxyProviders/types"; let _providersOverrideForTests: FreeProxyProvider[] | null = null; @@ -51,16 +55,19 @@ export async function POST(request: Request) { for (const provider of providers) { try { results[provider.id] = await provider.sync(); + await clearFreeProxySyncErrors(provider.id); } catch (error) { // #5595: isolate per-source failures so one provider throwing doesn't // abort the whole sync — the other sources still populate the pool and // the failure is surfaced in `results` instead of a blanket 500. + const errorMessage = error instanceof Error ? error.message : String(error); results[provider.id] = { fetched: 0, added: 0, updated: 0, - errors: [error instanceof Error ? error.message : String(error)], + errors: [errorMessage], }; + await recordFreeProxySyncErrors(provider.id, [errorMessage]); } } diff --git a/src/app/api/settings/proxies/[id]/repair-relay/route.ts b/src/app/api/settings/proxies/[id]/repair-relay/route.ts new file mode 100644 index 0000000000..3c2b075866 --- /dev/null +++ b/src/app/api/settings/proxies/[id]/repair-relay/route.ts @@ -0,0 +1,102 @@ +import { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { getProxyById, updateProxy } from "@/lib/localDb"; +import { decrypt } from "@/lib/db/encryption"; +import { isRelayProxyType, relayRepairMode } from "@/lib/db/proxies/mappers"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; + +const idParamSchema = z.object({ id: z.string().min(1) }); + +/** + * POST /api/settings/proxies/[id]/repair-relay + * + * Recovers a relay's auth IN PLACE without a full redeploy. The deploy token is + * never persisted, but when STORAGE_ENCRYPTION_KEY is set the deploy routes + * wrote an encrypted `relayAuthEnc` blob into proxy `notes`. That blob is the + * recoverable secret — we decrypt it and write the plaintext `relayAuth` back, + * so the relay works again without re-entering any deploy credentials. + * + * Returns: + * 200 { repaired: false, mode: "noop" } — plaintext already present + * 200 { repaired: true, mode: "recovered" } — re-derived from relayAuthEnc + * 409 { repaired: false, mode: "redeploy" } — token unrecoverable, redeploy + * 400 — not a relay-type proxy + * 404 — proxy not found + */ +export async function POST( + request: Request, + context: { params: Promise<{ id: string }> | { id: string } } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const params = await context.params; + const idValidation = validateBody(idParamSchema, params); + if (isValidationFailure(idValidation)) { + return createErrorResponse({ + status: 400, + message: idValidation.error.message, + type: "invalid_request", + }); + } + const { id } = idValidation.data; + + try { + const proxy = await getProxyById(id, { includeSecrets: true }); + if (!proxy) { + return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); + } + + if (!isRelayProxyType(proxy.type)) { + return createErrorResponse({ + status: 400, + message: "Repair is only available for relay proxies (vercel/deno/cloudflare)", + type: "invalid_request", + }); + } + + const mode = relayRepairMode(proxy.notes, proxy.type); + if (mode === "noop") { + return Response.json({ repaired: false, mode }); + } + + if (mode === "redeploy") { + return createErrorResponse({ + status: 409, + message: + "Relay auth is unrecoverable (no stored token, encrypted blob absent or undecryptable — " + + "likely a STORAGE_ENCRYPTION_KEY rotation). Redeploy the relay to write a fresh relayAuth.", + type: "conflict", + }); + } + + // mode === "recovered": read the still-encrypted blob, decrypt, write plaintext. + let enc: string | undefined; + let existingNotes: Record = {}; + try { + const parsed = JSON.parse(proxy.notes ?? "{}") as Record; + existingNotes = parsed; + if (typeof parsed.relayAuthEnc === "string") enc = parsed.relayAuthEnc; + } catch { + enc = undefined; + } + const decrypted = typeof enc === "string" ? decrypt(enc) : undefined; + if (!decrypted) { + return createErrorResponse({ + status: 409, + message: + "Relay auth is unrecoverable (encrypted blob could not be decrypted — likely a " + + "STORAGE_ENCRYPTION_KEY rotation). Redeploy the relay to write a fresh relayAuth.", + type: "conflict", + }); + } + + // Merge relayAuth into existing notes; drop relayAuthEnc since plaintext is now present. + const { relayAuthEnc: _dropped, ...rest } = existingNotes as { relayAuthEnc?: unknown }; + await updateProxy(id, { notes: JSON.stringify({ ...rest, relayAuth: decrypted }) }); + return Response.json({ repaired: true, mode: "recovered" }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to repair relay"); + } +} diff --git a/src/app/api/settings/proxies/route.ts b/src/app/api/settings/proxies/route.ts index 62abcaf344..6c201674bf 100644 --- a/src/app/api/settings/proxies/route.ts +++ b/src/app/api/settings/proxies/route.ts @@ -7,6 +7,13 @@ import { } from "@/lib/api/proxyRegistryRouteHandlers"; import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { + isRelayAuthMissing, + isRelayProxyType, + redactProxySecrets, + relayRepairMode, +} from "@/lib/db/proxies/mappers"; +import { getRelayProbeStats } from "@/lib/db/relayProbeStats"; export async function GET(request: Request) { const authError = await requireManagementAuth(request); @@ -16,13 +23,25 @@ export async function GET(request: Request) { const lookupResponse = await resolveProxyLookupResponse(searchParams, "whereUsed"); if (lookupResponse) return lookupResponse; - const proxies = await listProxies({ includeSecrets: false }); - // #3508: expose the SOCKS5 feature flag at runtime so the dashboard reflects the live - // ENABLE_SOCKS5_PROXY env (the UI previously gated on NEXT_PUBLIC_*, which is baked at - // build time and ignored a runtime Docker env). + // Load with secrets so we can derive relay repair state (whether a relay's + // auth is missing and whether it can be recovered in place vs needs a + // redeploy). The secrets themselves never leave the server — we redact each + // row before responding and only surface the derived relayInfo booleans. + const rawProxies = await listProxies({ includeSecrets: true }); + const items = rawProxies.map((p) => ({ + ...redactProxySecrets(p), + relayInfo: { + isRelay: isRelayProxyType(p.type), + authMissing: isRelayAuthMissing(p.notes, p.type), + repairMode: relayRepairMode(p.notes, p.type), + }, + })); return Response.json({ - items: proxies, - total: proxies.length, + items, + total: items.length, + // #5890: coarse relay health pulse for the dashboard — how many relay + // probes have run, and how many came back alive. + relayProbeStats: getRelayProbeStats(), // Default ON (opt-out): only an explicit falsey value disables SOCKS5. socks5Enabled: !["false", "0", "no", "off"].includes( (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase() diff --git a/src/app/api/settings/proxy/test/relayTestResult.ts b/src/app/api/settings/proxy/test/relayTestResult.ts index a6e2d4afee..09f5e93e9c 100644 --- a/src/app/api/settings/proxy/test/relayTestResult.ts +++ b/src/app/api/settings/proxy/test/relayTestResult.ts @@ -10,14 +10,29 @@ export interface RelayTestResult { latencyMs: number; proxyUrl: string; error?: string; + relay?: RelayAwareness; } +// Echoed from the relay response headers so the dashboard can show which +// backend actually answered, how many hops it tried, and whether it fell back. +export interface RelayAwareness { + url: string | null; + mode: string | null; + attempts: number | null; + fallback: boolean | null; +} + +// Minimal header accessor so both the DOM `Headers` type and undici's +// `IncomingHttpHeaders` (which only exposes `.get(name)`) can be passed in. +type HeaderAccessor = { get(name: string): string | null }; + export function buildRelayTestResult(input: { statusCode: number; publicIp: string | null; latencyMs: number; relayUrl: string; relayAuthPresent: boolean; + relayResponseHeaders?: HeaderAccessor; }): RelayTestResult { const { statusCode, publicIp, latencyMs, relayUrl, relayAuthPresent } = input; const success = statusCode === 200; @@ -30,6 +45,23 @@ export function buildRelayTestResult(input: { : " — no relay auth token was found; redeploy the relay, or check for a STORAGE_ENCRYPTION_KEY rotation"; } result.error = error; + } else if (input.relayResponseHeaders) { + result.relay = parseRelayAwareness(input.relayResponseHeaders); } return result; } + +function parseRelayAwareness(headers: HeaderAccessor): RelayAwareness { + const read = (name: string): string | null => { + const value = headers.get(name); + return value === null ? null : value; + }; + const attemptsRaw = read("x-relay-attempts"); + const fallbackRaw = read("x-relay-fallback"); + return { + url: read("x-relay-url"), + mode: read("x-relay-mode"), + attempts: attemptsRaw === null ? null : Number(attemptsRaw) || null, + fallback: fallbackRaw === null ? null : fallbackRaw === "true", + }; +} diff --git a/src/app/api/settings/proxy/test/route.ts b/src/app/api/settings/proxy/test/route.ts index dbe44967b4..c1eaf085b9 100644 --- a/src/app/api/settings/proxy/test/route.ts +++ b/src/app/api/settings/proxy/test/route.ts @@ -14,6 +14,7 @@ import { extractRelayAuth } from "@/lib/db/proxies"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { buildRelayTestResult } from "./relayTestResult"; +import { recordRelayProbe } from "@/lib/db/relayProbeStats"; const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]); @@ -128,7 +129,16 @@ export async function POST(request: Request) { latencyMs: Date.now() - start, relayUrl, relayAuthPresent: relayAuth.length > 0, + relayResponseHeaders: { + get: (name: string) => { + const value = res.headers[name.toLowerCase()]; + return value === undefined ? null : String(value); + }, + }, }); + // #5890: track relay probe outcomes so the dashboard can surface a + // relayTested / relayAlive pulse and flag an unhealthy sidecar backend. + recordRelayProbe(relayResult.success); // #5716: a relay that *responds* non-200 (e.g. 401 auth mismatch) used to // return `success:false` with no reason and no log — a silent failure. if (!relayResult.success) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 477b243916..cde54389bf 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5236,6 +5236,15 @@ "proxyFreePoolQuality": "Quality", "proxyFreePoolLatency": "Latency", "proxyFreePoolEmpty": "No proxies found. Click Sync All to fetch from sources.", + "proxyFreePoolSearchPlaceholder": "Search host…", + "proxyFreePoolSearchLabel": "Search proxies by host", + "proxyFreePoolSortLabel": "Sort proxies", + "proxyFreePoolSortQuality": "Quality", + "proxyFreePoolSortLatency": "Latency", + "proxyFreePoolSortRecent": "Recently validated", + "proxyFreePoolListTotal": "Listed", + "proxyFreePoolLoadMore": "Load more", + "loading": "Loading…", "close": "Close", "cancel": "Cancel", "globalLabel": "Global", @@ -7991,6 +8000,13 @@ "testSuccess": "✓ {ip}", "testLatency": "{latency}ms", "testFailure": "✗ {error}", + "repair": "Repair", + "relayAuthMissing": "auth missing", + "relayRepairTooltip": "Recover relay auth in place. If the token is unrecoverable (e.g. after a STORAGE_ENCRYPTION_KEY rotation), redeploy the relay instead.", + "relayRepairRedeployRequired": "Relay auth is unrecoverable — redeploy the relay to write a fresh token.", + "relayRepairFailed": "Relay repair failed", + "relayRepairError": "repair failed", + "relayProbeSummary": "Relay probes: {alive}/{tested} alive", "bulkImport": "Bulk Import", "bulkImportTitle": "Bulk Import Proxies", "bulkImportDescription": "Paste proxy profiles one per line. Supported formats: pipe-delimited (NAME|HOST|PORT|…), shorthand (ip:port, ip:port:user:pass, user:pass@ip:port, user:pass:ip:port, protocol://ip:port, protocol://user:pass@ip:port), or protocol header mode (bare protocol on first line applies to subsequent shorthand lines). Existing proxies (same host + port) will be updated.", diff --git a/src/lib/api/proxyRegistryRouteHandlers.ts b/src/lib/api/proxyRegistryRouteHandlers.ts index dc93873956..b5b2e9bf4e 100644 --- a/src/lib/api/proxyRegistryRouteHandlers.ts +++ b/src/lib/api/proxyRegistryRouteHandlers.ts @@ -94,7 +94,13 @@ export async function handleProxyUpdate(request: Request) { }); } - const { id, assignment, ...changes } = validation.data; + const { id, assignment, ...rawChanges } = validation.data; + // Strip keys the client didn't send — .partial() resolves absent fields to + // undefined, which would silently overwrite DB values via the spread merge + // in updateProxyRow. Only include keys the client explicitly provided. + const changes = Object.fromEntries( + Object.entries(rawChanges).filter(([_, v]) => v !== undefined) + ); if (assignment) { const result = await updateProxyAndAssign(id, changes, assignment); if (!result?.proxy) { diff --git a/src/lib/db/freeProxies.ts b/src/lib/db/freeProxies.ts index c75642d491..3a63c9f7a0 100644 --- a/src/lib/db/freeProxies.ts +++ b/src/lib/db/freeProxies.ts @@ -28,6 +28,9 @@ export interface FreeProxyStats { lastSyncAt: string | null; } +/** Source id → list of error messages from that source's last sync. */ +export type FreeProxySyncErrors = Record; + type DbRow = Record; function mapRow(row: unknown): FreeProxyRecord { @@ -109,6 +112,8 @@ export async function listFreeProxies(options?: { minQuality?: number; onlyInPool?: boolean; onlyNotInPool?: boolean; + search?: string; + sortBy?: "quality" | "latency" | "recent"; limit?: number; offset?: number; }): Promise { @@ -138,8 +143,18 @@ export async function listFreeProxies(options?: { if (options?.onlyNotInPool) { sql += " AND in_pool = 0"; } + if (options?.search) { + sql += " AND host LIKE ?"; + params.push(`%${options.search}%`); + } - sql += " ORDER BY quality_score DESC, last_validated DESC"; + const sortClause = + options?.sortBy === "latency" + ? "ORDER BY latency_ms IS NULL, latency_ms ASC" + : options?.sortBy === "recent" + ? "ORDER BY last_validated DESC" + : "ORDER BY quality_score DESC, last_validated DESC"; + sql += ` ${sortClause}`; if (options?.limit) { sql += " LIMIT ?"; @@ -154,6 +169,51 @@ export async function listFreeProxies(options?: { return rows.map(mapRow); } +export async function countFreeProxies(options?: { + sources?: FreeProxySourceId[]; + protocol?: string; + country?: string; + minQuality?: number; + onlyInPool?: boolean; + onlyNotInPool?: boolean; + search?: string; +}): Promise { + const db = getDbInstance(); + const params: unknown[] = []; + let sql = "SELECT COUNT(*) AS count FROM free_proxies WHERE 1=1"; + + if (options?.sources?.length) { + sql += ` AND source IN (${options.sources.map(() => "?").join(",")})`; + params.push(...options.sources); + } + if (options?.protocol) { + sql += " AND type = ?"; + params.push(options.protocol); + } + if (options?.country) { + sql += " AND country_code = ?"; + params.push(options.country.toUpperCase()); + } + if (options?.minQuality != null) { + sql += " AND quality_score >= ?"; + params.push(options.minQuality); + } + if (options?.onlyInPool) { + sql += " AND in_pool = 1"; + } + if (options?.onlyNotInPool) { + sql += " AND in_pool = 0"; + } + if (options?.search) { + sql += " AND host LIKE ?"; + params.push(`%${options.search}%`); + } + + const row = db.prepare(sql).get(...params) as DbRow | undefined; + const count = row?.count; + return typeof count === "number" ? count : Number(count ?? 0); +} + export async function listFreeProxiesBySource( source: FreeProxySourceId, filters: { @@ -313,9 +373,11 @@ const FREE_PROXY_SYNC_KEY = "last_sync_at"; export async function recordFreeProxySync(at?: string): Promise { const db = getDbInstance(); const ts = at ?? new Date().toISOString(); - db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" - ).run(FREE_PROXY_SYNC_NAMESPACE, FREE_PROXY_SYNC_KEY, ts); + db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( + FREE_PROXY_SYNC_NAMESPACE, + FREE_PROXY_SYNC_KEY, + ts + ); backupDbFile("pre-write"); return ts; } @@ -358,3 +420,50 @@ export async function getFreeProxyStats(): Promise { lastSyncAt: recordedSyncAt ?? derivedSyncAt, }; } +/** + * Persist the most recent per-source sync errors. A successful sync for a + * source should call clearFreeProxySyncErrors instead so the stale error + * stops showing. + */ +export async function recordFreeProxySyncErrors( + source: FreeProxySourceId, + errors: string[] +): Promise { + const db = getDbInstance(); + const at = new Date().toISOString(); + db.prepare( + "INSERT OR REPLACE INTO free_proxy_sync_errors (source, errors, updated_at) VALUES (?, ?, ?)" + ).run(source, JSON.stringify(errors), at); + backupDbFile("pre-write"); +} + +/** Clear a source's stored sync error (called on a successful sync). */ +export async function clearFreeProxySyncErrors(source: FreeProxySourceId): Promise { + const db = getDbInstance(); + db.prepare("DELETE FROM free_proxy_sync_errors WHERE source = ?").run(source); + backupDbFile("pre-write"); +} + +/** + * Read all stored per-source sync errors as a plain map. Sources with no + * recorded error are omitted, so an empty object means a clean (or un-synced) + * state. + */ +export async function getFreeProxySyncErrors(): Promise { + const db = getDbInstance(); + const rows = db.prepare("SELECT source, errors FROM free_proxy_sync_errors").all() as Array<{ + source: string; + errors: string; + }>; + const out: FreeProxySyncErrors = {}; + for (const row of rows) { + if (!row.source) continue; + try { + const parsed = JSON.parse(row.errors) as unknown; + out[row.source] = Array.isArray(parsed) ? parsed.map(String) : [String(row.errors)]; + } catch { + out[row.source] = [String(row.errors)]; + } + } + return out; +} diff --git a/src/lib/db/migrations/122_free_proxy_sync_errors.sql b/src/lib/db/migrations/122_free_proxy_sync_errors.sql new file mode 100644 index 0000000000..ee98c70e26 --- /dev/null +++ b/src/lib/db/migrations/122_free_proxy_sync_errors.sql @@ -0,0 +1,10 @@ +-- 122_free_proxy_sync_errors.sql +-- Per-source sync errors for the free-proxy pool. Each failed source writes its +-- last error(s) here, keyed by source id, so a "Total: 0" result is honest +-- instead of silent. Cleared on a successful sync for that source. + +CREATE TABLE IF NOT EXISTS free_proxy_sync_errors ( + source TEXT PRIMARY KEY, + errors TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/src/lib/db/proxies/mappers.ts b/src/lib/db/proxies/mappers.ts index ac88149b1d..c475ad1b64 100644 --- a/src/lib/db/proxies/mappers.ts +++ b/src/lib/db/proxies/mappers.ts @@ -1,4 +1,4 @@ -import { decrypt } from "../encryption"; +import { decrypt, looksEncrypted } from "../encryption"; import type { JsonRecord, ProxyScope, @@ -68,12 +68,68 @@ export function extractRelayAuth(notes: unknown): string | undefined { if (parsed.relayAuthEnc) { const dec = decrypt(parsed.relayAuthEnc); if (dec) return dec; + // decrypt returned null despite a present blob — warn so operators + // know the key changed or went missing. The plaintext fallback below + // may still save us (legacy rows that never migrated). + if (looksEncrypted(parsed.relayAuthEnc)) { + console.warn( + `[relay] Failed to decrypt relayAuthEnc for proxy — ` + + `STORAGE_ENCRYPTION_KEY may have changed or been unset` + ); + } } return parsed.relayAuth || undefined; } catch { return undefined; } } +/** + * True when a relay-type proxy has no usable auth in `notes` — neither a + * plaintext `relayAuth` nor a still-decryptable `relayAuthEnc` blob. This is + * the state that makes proxyFetch throw `missing relayAuth` at request time. + * Non-relay types are never "missing" (they have no relay auth to begin with). + */ +export function isRelayAuthMissing(notes: unknown, type: unknown): boolean { + return isRelayProxyType(type) && extractRelayAuth(notes) === undefined; +} + +export type RelayRepairMode = "noop" | "recovered" | "redeploy" | null; + +/** + * Classifies how a relay's missing/uncertain auth can be fixed WITHOUT a full + * redeploy (the deploy token is never persisted): + * - "noop": plaintext relayAuth already present — nothing to do. + * - "recovered": relayAuthEnc blob present and decrypts — re-derive plaintext + * in place (key still set, blob intact). + * - "redeploy": neither recoverable — token is unrecoverable, must redeploy. + * - null: not a relay type (repair is N/A). + */ +export function relayRepairMode(notes: unknown, type: unknown): RelayRepairMode { + if (!isRelayProxyType(type)) return null; + // Plaintext relayAuth already in place: nothing to do. + const parsed = safeParseNotes(notes); + if (typeof parsed?.relayAuth === "string" && parsed.relayAuth) return "noop"; + // Encrypted blob present and still decryptable: re-derive plaintext in place. + if ( + typeof parsed?.relayAuthEnc === "string" && + parsed.relayAuthEnc && + decrypt(parsed.relayAuthEnc) + ) { + return "recovered"; + } + // Neither recoverable: the deploy token (never persisted) is lost → redeploy. + return "redeploy"; +} + +function safeParseNotes(notes: unknown): { relayAuth?: string; relayAuthEnc?: string } | null { + if (typeof notes !== "string") return null; + try { + const parsed = JSON.parse(notes) as { relayAuth?: string; relayAuthEnc?: string }; + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} export function toRegistryProxyResolution(row: unknown, level: ProxyScope, levelId: string | null) { const record = toRecord(row); diff --git a/src/lib/db/relayProbeStats.ts b/src/lib/db/relayProbeStats.ts new file mode 100644 index 0000000000..914ac13b68 --- /dev/null +++ b/src/lib/db/relayProbeStats.ts @@ -0,0 +1,28 @@ +// In-process relay probe counters. We deliberately keep these in memory rather +// than persisting to the DB: the dashboard only needs a coarse "how many relay +// probes have we run, and how many came back alive" signal to flag a sidecar +// backend that is drifting unhealthy. They reset on restart, which is acceptable +// for an operational pulse. + +interface RelayProbeStats { + tested: number; + alive: number; +} + +let stats: RelayProbeStats = { tested: 0, alive: 0 }; + +export function recordRelayProbe(alive: boolean): void { + // Single-threaded JS; no lock needed. Guard against pathological values. + stats = { + tested: stats.tested + 1, + alive: stats.alive + (alive ? 1 : 0), + }; +} + +export function getRelayProbeStats(): RelayProbeStats { + return { ...stats }; +} + +export function resetRelayProbeStats(): void { + stats = { tested: 0, alive: 0 }; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 34468a6cdb..5cc8b6a76e 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -559,6 +559,7 @@ export type { export { upsertFreeProxy, listFreeProxies, + countFreeProxies, listFreeProxiesBySource, getFreeProxyById, markFreeProxyInPool, @@ -568,9 +569,12 @@ export { pruneStaleFreeProxies, getFreeProxyStats, recordFreeProxySync, + recordFreeProxySyncErrors, + clearFreeProxySyncErrors, + getFreeProxySyncErrors, } from "./db/freeProxies"; -export type { FreeProxyRecord, FreeProxyStats } from "./db/freeProxies"; +export type { FreeProxyRecord, FreeProxyStats, FreeProxySyncErrors } from "./db/freeProxies"; export { listPlaygroundPresets, diff --git a/src/shared/validation/freeProxySchemas.ts b/src/shared/validation/freeProxySchemas.ts index 445e9c90c0..39d3e6cd03 100644 --- a/src/shared/validation/freeProxySchemas.ts +++ b/src/shared/validation/freeProxySchemas.ts @@ -15,6 +15,8 @@ export const freeProxyListSchema = z.object({ .optional() .transform((v) => v?.toUpperCase()), minQuality: z.coerce.number().int().min(0).max(100).optional(), + search: z.string().trim().min(1).max(128).optional(), + sortBy: z.enum(["quality", "latency", "recent"]).optional(), limit: z.coerce.number().int().min(1).max(1000).optional(), offset: z.coerce.number().int().min(0).optional(), onlyNotInPool: z @@ -87,10 +89,7 @@ export const cloudflareDeploySchema = z.object({ .string() .min(8, "Cloudflare Account ID looks too short") .max(64) - .regex( - /^[a-f0-9]+$/, - "Cloudflare Account ID must be lowercase hex" - ), + .regex(/^[a-f0-9]+$/, "Cloudflare Account ID must be lowercase hex"), // Cloudflare API tokens are opaque alphanumeric (40+ chars) — same alphabet // we accept for Vercel tokens; constrain length to catch paste accidents. apiToken: z @@ -108,4 +107,3 @@ export const cloudflareDeploySchema = z.object({ .regex(/^[a-z0-9-]+$/, "Worker name must be lowercase alphanumeric with hyphens") .default("omniroute-relay"), }); - diff --git a/src/shared/validation/schemas/proxy.ts b/src/shared/validation/schemas/proxy.ts index db3e009a74..ff49b75e78 100644 --- a/src/shared/validation/schemas/proxy.ts +++ b/src/shared/validation/schemas/proxy.ts @@ -108,8 +108,7 @@ export const proxyRegistryFieldsSchema = z (value) => (typeof value === "string" ? value.trim().toLowerCase() : value), z.enum(["http", "https", "socks5", "vercel", "deno", "cloudflare"]) ) - .optional() - .default("http"), + .optional(), host: z.string().trim().min(1, "host is required").max(255), port: z.coerce.number().int().min(1).max(65535), username: z.string().optional(), @@ -135,6 +134,13 @@ export const proxyRegistryFieldsSchema = z export const createProxyRegistrySchema = proxyRegistryFieldsSchema .extend({ + type: z + .preprocess( + (value) => (typeof value === "string" ? value.trim().toLowerCase() : value), + z.enum(["http", "https", "socks5", "vercel", "deno", "cloudflare"]) + ) + .optional() + .default("http"), assignment: inlineProxyAssignmentSchema.optional(), }) .strict(); diff --git a/tests/unit/api/free-proxies-list-route.test.ts b/tests/unit/api/free-proxies-list-route.test.ts new file mode 100644 index 0000000000..334148de5b --- /dev/null +++ b/tests/unit/api/free-proxies-list-route.test.ts @@ -0,0 +1,104 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { FreeProxyItem } from "@/lib/freeProxyProviders/types"; +import { makeManagementSessionRequest } from "../../helpers/managementSession.ts"; + +// DATA_DIR must be set before the DB core module evaluates its singleton. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-list-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../../src/lib/db/core.ts"); +const freeProxiesDb = await import("../../../src/lib/db/freeProxies.ts"); +const settingsDb = await import("../../../src/lib/db/settings.ts"); +const listRoute = await import("../../../src/app/api/settings/free-proxies/route.ts"); + +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + +async function reset() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function make(host: string, quality: number, latency: number): FreeProxyItem { + return { + source: "1proxy", + host, + port: 8080, + type: "http", + countryCode: "US", + qualityScore: quality, + latencyMs: latency, + anonymity: "elite", + lastValidated: "2026-01-01T00:00:00.000Z", + }; +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; +}); + +test("GET returns the full contract { proxies, total, hasMore, stats, syncErrors }", async () => { + await reset(); + await freeProxiesDb.upsertFreeProxy(make("alpha.example", 90, 100)); + await freeProxiesDb.upsertFreeProxy(make("bravo.example", 40, 300)); + + const res = await listRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings/free-proxies?sortBy=quality") + ); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.success, true); + + const { proxies, total, stats, syncErrors } = body.data; + assert.equal(total, 2); + assert.equal(proxies.length, 2); + assert.equal(stats.total, 2); + assert.deepEqual(syncErrors, {}); + // quality sort → first item is the highest score. + assert.equal(proxies[0].qualityScore, 90); +}); + +test("GET search param filters the returned proxies and total", async () => { + await reset(); + await freeProxiesDb.upsertFreeProxy(make("alpha.example", 90, 100)); + await freeProxiesDb.upsertFreeProxy(make("bravo.example", 40, 300)); + + const res = await listRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings/free-proxies?search=alph") + ); + const body = await res.json(); + assert.equal(body.data.total, 1); + assert.equal(body.data.proxies[0].host, "alpha.example"); +}); + +test("GET surfaces syncErrors when a source previously failed", async () => { + await reset(); + await freeProxiesDb.recordFreeProxySyncErrors("proxifly", ["HTTP 429 from upstream"]); + + const res = await listRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings/free-proxies") + ); + const body = await res.json(); + assert.deepEqual(body.data.syncErrors, { proxifly: ["HTTP 429 from upstream"] }); +}); + +test("GET requires management auth", async () => { + await reset(); + // A fresh DB has no configured password, so a loopback/unauthenticated + // request is treated as the pre-setup bootstrap path and allowed through + // (see `isAuthRequired` in src/shared/utils/apiAuth.ts). Configure a + // password + requireLogin so this test actually exercises the auth gate, + // matching the pattern used by tests/unit/api/settings-audit.test.ts. + process.env.INITIAL_PASSWORD = "free-proxies-list-route-test-password"; + await settingsDb.updateSettings({ requireLogin: true }); + + const res = await listRoute.GET(new Request("http://localhost/api/settings/free-proxies")); + assert.equal(res.status, 401); +}); diff --git a/tests/unit/api/free-proxies-route.test.ts b/tests/unit/api/free-proxies-route.test.ts new file mode 100644 index 0000000000..77f3c034ee --- /dev/null +++ b/tests/unit/api/free-proxies-route.test.ts @@ -0,0 +1,107 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { makeManagementSessionRequest } from "../../helpers/managementSession.ts"; + +// DATA_DIR must be set before the DB core module evaluates its singleton. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-api-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../../src/lib/db/core.ts"); +const freeProxiesDb = await import("../../../src/lib/db/freeProxies.ts"); +const settingsDb = await import("../../../src/lib/db/settings.ts"); +const listRoute = await import("../../../src/app/api/settings/free-proxies/route.ts"); + +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; +}); + +function seed(host: string, quality: number, latency: number) { + return freeProxiesDb.upsertFreeProxy({ + source: "1proxy", + host, + port: 8080, + type: "http", + countryCode: "US", + qualityScore: quality, + latencyMs: latency, + anonymity: "elite", + lastValidated: "2026-01-01T00:00:00.000Z", + }); +} + +test("GET returns the full { proxies, total, hasMore, stats, syncErrors } shape", async () => { + await seed("alpha.example", 90, 100); + await seed("bravo.example", 40, 300); + + const res = await listRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings/free-proxies") + ); + assert.equal(res.status, 200); + const body = (await res.json()) as { + success: boolean; + data: { + proxies: unknown[]; + total: number; + hasMore: boolean; + stats: { total: number; inPool: number; bySource: Array<{ source: string; count: number }> }; + syncErrors: Record; + }; + }; + assert.equal(body.success, true); + assert.equal(body.data.total, 2); + assert.equal(body.data.proxies.length, 2); + assert.equal(body.data.hasMore, false); + assert.equal(body.data.stats.total, 2); + assert.deepEqual(body.data.syncErrors, {}); +}); + +test("GET search + limit via query params is reflected in the response", async () => { + await seed("alpha.example", 90, 100); + await seed("bravo.example", 40, 300); + + const res = await listRoute.GET( + await makeManagementSessionRequest( + "http://localhost/api/settings/free-proxies?search=alph&limit=1" + ) + ); + assert.equal(res.status, 200); + const body = (await res.json()) as { + data: { proxies: unknown[]; total: number; hasMore: boolean }; + }; + assert.equal(body.data.total, 1); + assert.equal(body.data.proxies.length, 1); + assert.equal((body.data.proxies[0] as { host: string }).host, "alpha.example"); + assert.equal(body.data.hasMore, false); +}); + +test("GET rejects an unauthenticated request with 401", async () => { + // A fresh DB has no configured password, so a loopback/unauthenticated + // request is treated as the pre-setup bootstrap path and allowed through + // (see `isAuthRequired` in src/shared/utils/apiAuth.ts). Configure a + // password + requireLogin so this test actually exercises the auth gate, + // matching the pattern used by tests/unit/api/settings-audit.test.ts. + process.env.INITIAL_PASSWORD = "free-proxies-route-test-password"; + await settingsDb.updateSettings({ requireLogin: true }); + + const res = await listRoute.GET(new Request("http://localhost/api/settings/free-proxies")); + assert.equal(res.status, 401); +}); diff --git a/tests/unit/api/proxies-repair-relay.test.ts b/tests/unit/api/proxies-repair-relay.test.ts new file mode 100644 index 0000000000..39d89ab6c4 --- /dev/null +++ b/tests/unit/api/proxies-repair-relay.test.ts @@ -0,0 +1,130 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { encrypt } from "@/lib/db/encryption"; +import { makeManagementSessionRequest } from "../../helpers/managementSession.ts"; + +// DATA_DIR must be set before the DB core module evaluates its singleton. The +// management session helper also requires JWT_SECRET, set per-test. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-repair-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const ORIGINAL_KEY = process.env.STORAGE_ENCRYPTION_KEY; +process.env.STORAGE_ENCRYPTION_KEY = "test-repair-encryption-key"; + +const core = await import("../../../src/lib/db/core.ts"); +const proxiesDb = await import("../../../src/lib/db/proxies.ts"); +const repairRelayRoute = + await import("../../../src/app/api/settings/proxies/[id]/repair-relay/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_KEY === undefined) delete process.env.STORAGE_ENCRYPTION_KEY; + else process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_KEY; +}); + +async function createProxy(type: string, notes: string): Promise { + const created = await proxiesDb.createProxy({ + name: `proxy-${type}`, + type, + host: "proxy.example", + port: 443, + notes, + source: "dashboard-custom", + }); + return created.id; +} + +async function createRelay(notes: string): Promise { + return createProxy("vercel", notes); +} + +test("returns 404 when the proxy does not exist", async () => { + const res = await repairRelayRoute.POST( + await makeManagementSessionRequest("http://localhost/api/settings/proxies/nope/repair-relay", { + method: "POST", + }), + { params: Promise.resolve({ id: "nope" }) } + ); + assert.equal(res.status, 404); +}); + +test("returns 400 when the proxy is not a relay type", async () => { + const id = await createProxy("http", JSON.stringify({})); + const res = await repairRelayRoute.POST( + await makeManagementSessionRequest(`http://localhost/api/settings/proxies/${id}/repair-relay`, { + method: "POST", + }), + { params: Promise.resolve({ id }) } + ); + assert.equal(res.status, 400); +}); + +test("returns 404 when the proxy does not exist", async () => { + const res = await repairRelayRoute.POST( + await makeManagementSessionRequest("http://localhost/api/settings/proxies/nope/repair-relay", { + method: "POST", + }), + { params: Promise.resolve({ id: "nope" }) } + ); + assert.equal(res.status, 404); +}); + +test('mode "noop" when plaintext relayAuth already present', async () => { + const id = await createRelay(JSON.stringify({ relayAuth: "plain-token" })); + const res = await repairRelayRoute.POST( + await makeManagementSessionRequest(`http://localhost/api/settings/proxies/${id}/repair-relay`, { + method: "POST", + }), + { params: Promise.resolve({ id }) } + ); + assert.equal(res.status, 200); + const body = await res.json(); + assert.deepEqual(body, { repaired: false, mode: "noop" }); +}); + +test('mode "recovered" re-derives plaintext from the encrypted blob', async () => { + const enc = encrypt("recoverable-token"); + const id = await createRelay(JSON.stringify({ relayAuthEnc: enc })); + const res = await repairRelayRoute.POST( + await makeManagementSessionRequest(`http://localhost/api/settings/proxies/${id}/repair-relay`, { + method: "POST", + }), + { params: Promise.resolve({ id }) } + ); + assert.equal(res.status, 200); + const body = await res.json(); + assert.deepEqual(body, { repaired: true, mode: "recovered" }); + + const updated = await proxiesDb.getProxyById(id, { includeSecrets: true }); + const parsed = JSON.parse(updated?.notes ?? "{}"); + assert.equal(parsed.relayAuth, "recoverable-token"); +}); + +test('mode "redeploy" (409) when no recoverable auth exists', async () => { + const id = await createRelay(JSON.stringify({})); + const res = await repairRelayRoute.POST( + await makeManagementSessionRequest(`http://localhost/api/settings/proxies/${id}/repair-relay`, { + method: "POST", + }), + { params: Promise.resolve({ id }) } + ); + assert.equal(res.status, 409); + const body = await res.json(); + assert.ok( + typeof body.error?.message === "string" && /redeploy/i.test(body.error.message), + `409 must explain redeploy; got ${JSON.stringify(body)}` + ); +}); diff --git a/tests/unit/free-proxies-list-search.test.ts b/tests/unit/free-proxies-list-search.test.ts new file mode 100644 index 0000000000..57dea9618b --- /dev/null +++ b/tests/unit/free-proxies-list-search.test.ts @@ -0,0 +1,97 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { FreeProxyItem } from "@/lib/freeProxyProviders/types"; + +// DATA_DIR must be set before the DB core module evaluates its singleton, so we +// make the temp dir + env assignment first, then dynamic-import the modules. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-list-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const freeProxiesDb = await import("../../src/lib/db/freeProxies.ts"); + +async function reset() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function make(host: string, quality: number, latency: number): FreeProxyItem { + return { + source: "1proxy", + host, + port: 8080, + type: "http", + countryCode: "US", + qualityScore: quality, + latencyMs: latency, + anonymity: "elite", + lastValidated: "2026-01-01T00:00:00.000Z", + }; +} + +test("listFreeProxies + countFreeProxies agree on a filtered total", async () => { + await reset(); + await freeProxiesDb.upsertFreeProxy(make("alpha.example", 90, 100)); + await freeProxiesDb.upsertFreeProxy(make("bravo.example", 40, 300)); + await freeProxiesDb.upsertFreeProxy(make("charlie.example", 70, 200)); + + const all = await freeProxiesDb.listFreeProxies({}); + assert.equal(all.length, 3); + assert.equal(await freeProxiesDb.countFreeProxies({}), 3); +}); + +test("search filters by host LIKE (case-sensitive on stored host)", async () => { + await reset(); + await freeProxiesDb.upsertFreeProxy(make("alpha.example", 90, 100)); + await freeProxiesDb.upsertFreeProxy(make("bravo.example", 40, 300)); + + const hit = await freeProxiesDb.listFreeProxies({ search: "alph" }); + assert.equal(hit.length, 1); + assert.equal(hit[0].host, "alpha.example"); + assert.equal(await freeProxiesDb.countFreeProxies({ search: "alph" }), 1); + assert.equal(await freeProxiesDb.countFreeProxies({ search: "zzz" }), 0); +}); + +test('sortBy "latency" orders by latency ascending with nulls last', async () => { + await reset(); + await freeProxiesDb.upsertFreeProxy(make("slow.example", 50, 500)); + await freeProxiesDb.upsertFreeProxy(make("fast.example", 50, 50)); + await freeProxiesDb.upsertFreeProxy(make("mid.example", 50, 200)); + + const sorted = await freeProxiesDb.listFreeProxies({ sortBy: "latency" }); + assert.deepEqual( + sorted.map((p) => p.host), + ["fast.example", "mid.example", "slow.example"] + ); +}); + +test('sortBy "quality" orders by quality descending (default)', async () => { + await reset(); + await freeProxiesDb.upsertFreeProxy(make("low.example", 10, 100)); + await freeProxiesDb.upsertFreeProxy(make("high.example", 99, 100)); + + const sorted = await freeProxiesDb.listFreeProxies({}); + assert.deepEqual( + sorted.map((p) => p.host), + ["high.example", "low.example"] + ); +}); + +test("pagination limit+offset is reflected in list but not count", async () => { + await reset(); + for (let i = 0; i < 5; i++) { + await freeProxiesDb.upsertFreeProxy(make(`host-${i}.example`, 10 + i, 100)); + } + const page = await freeProxiesDb.listFreeProxies({ limit: 2, offset: 1 }); + assert.equal(page.length, 2); + assert.equal(await freeProxiesDb.countFreeProxies({}), 5); +}); diff --git a/tests/unit/proxy-relay-mappers.test.ts b/tests/unit/proxy-relay-mappers.test.ts new file mode 100644 index 0000000000..6572aee695 --- /dev/null +++ b/tests/unit/proxy-relay-mappers.test.ts @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { encrypt } from "@/lib/db/encryption"; +import { + isRelayProxyType, + extractRelayAuth, + isRelayAuthMissing, + relayRepairMode, +} from "@/lib/db/proxies/mappers"; + +// A relay deploy writes an encrypted `relayAuthEnc` blob only when encryption is +// enabled. Flip it on so the "recovered" path has a decryptable blob to test. +const ORIGINAL_KEY = process.env.STORAGE_ENCRYPTION_KEY; +process.env.STORAGE_ENCRYPTION_KEY = "test-relay-repair-key"; + +test.after(() => { + if (ORIGINAL_KEY === undefined) delete process.env.STORAGE_ENCRYPTION_KEY; + else process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_KEY; +}); + +test("isRelayProxyType recognizes vercel/deno/cloudflare only", () => { + assert.equal(isRelayProxyType("vercel"), true); + assert.equal(isRelayProxyType("deno"), true); + assert.equal(isRelayProxyType("cloudflare"), true); + assert.equal(isRelayProxyType("http"), false); + assert.equal(isRelayProxyType(123), false); +}); + +test("extractRelayAuth prefers decrypted blob over legacy plaintext", () => { + const enc = encrypt("secret-token"); + assert.ok(enc && enc.startsWith("enc:v1:")); + const notes = JSON.stringify({ relayAuthEnc: enc }); + assert.equal(extractRelayAuth(notes), "secret-token"); +}); + +test("extractRelayAuth falls back to legacy plaintext relayAuth", () => { + const notes = JSON.stringify({ relayAuth: "plain-token" }); + assert.equal(extractRelayAuth(notes), "plain-token"); +}); + +test("extractRelayAuth returns undefined for non-relay / garbage notes", () => { + assert.equal(extractRelayAuth(null), undefined); + assert.equal(extractRelayAuth("not json"), undefined); + assert.equal(extractRelayAuth(JSON.stringify({})), undefined); +}); + +test("isRelayAuthMissing is false for a relay with readable plaintext auth", () => { + assert.equal(isRelayAuthMissing(JSON.stringify({ relayAuth: "plain" }), "vercel"), false); +}); + +test("isRelayAuthMissing is false when the encrypted blob still decrypts", () => { + const enc = encrypt("still-good"); + assert.equal(isRelayAuthMissing(JSON.stringify({ relayAuthEnc: enc }), "deno"), false); +}); + +test("isRelayAuthMissing is true when no auth is present on a relay", () => { + assert.equal(isRelayAuthMissing(JSON.stringify({}), "vercel"), true); + assert.equal(isRelayAuthMissing(null, "cloudflare"), true); +}); + +test("isRelayAuthMissing is always false for non-relay types", () => { + assert.equal(isRelayAuthMissing(null, "http"), false); + assert.equal(isRelayAuthMissing(JSON.stringify({}), "socks5"), false); +}); + +test('relayRepairMode "noop" when plaintext relayAuth already present', () => { + assert.equal(relayRepairMode(JSON.stringify({ relayAuth: "plain" }), "vercel"), "noop"); +}); + +test('relayRepairMode "recovered" when encrypted blob decrypts', () => { + const enc = encrypt("recover-me"); + assert.equal(relayRepairMode(JSON.stringify({ relayAuthEnc: enc }), "deno"), "recovered"); +}); + +test('relayRepairMode "redeploy" when no recoverable auth exists', () => { + assert.equal(relayRepairMode(JSON.stringify({}), "vercel"), "redeploy"); + assert.equal(relayRepairMode(null, "cloudflare"), "redeploy"); +}); + +test("relayRepairMode returns null for non-relay types", () => { + assert.equal(relayRepairMode(JSON.stringify({}), "http"), null); + assert.equal(relayRepairMode(null, "socks5"), null); +}); diff --git a/tests/unit/relay-probe-stats.test.ts b/tests/unit/relay-probe-stats.test.ts new file mode 100644 index 0000000000..14693f5e66 --- /dev/null +++ b/tests/unit/relay-probe-stats.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + recordRelayProbe, + getRelayProbeStats, + resetRelayProbeStats, +} from "@/lib/db/relayProbeStats"; + +test.beforeEach(() => { + resetRelayProbeStats(); +}); + +test("starts at zero", () => { + assert.deepEqual(getRelayProbeStats(), { tested: 0, alive: 0 }); +}); + +test("counts tested and alive probes", () => { + recordRelayProbe(true); + recordRelayProbe(false); + recordRelayProbe(true); + assert.deepEqual(getRelayProbeStats(), { tested: 3, alive: 2 }); +}); + +test("returns a copy so callers cannot mutate internal state", () => { + recordRelayProbe(true); + const snapshot = getRelayProbeStats(); + snapshot.tested = 999; + assert.deepEqual(getRelayProbeStats(), { tested: 1, alive: 1 }); +}); + +test("reset returns counters to zero", () => { + recordRelayProbe(false); + recordRelayProbe(false); + resetRelayProbeStats(); + assert.deepEqual(getRelayProbeStats(), { tested: 0, alive: 0 }); +}); diff --git a/tests/unit/relay-test-result-awareness.test.ts b/tests/unit/relay-test-result-awareness.test.ts new file mode 100644 index 0000000000..17ce5c0b3d --- /dev/null +++ b/tests/unit/relay-test-result-awareness.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildRelayTestResult } from "@/app/api/settings/proxy/test/relayTestResult"; + +const base = { publicIp: "1.2.3.4", latencyMs: 12, relayUrl: "https://relay.example" }; + +// Minimal HeaderAccessor so we do not depend on a concrete Headers implementation. +function headers(map: Record): { get(name: string): string | null } { + return { get: (name: string) => (name in map ? map[name] : null) }; +} + +test("parses relay awareness from x-relay-* response headers on success", () => { + const r = buildRelayTestResult({ + ...base, + statusCode: 200, + relayAuthPresent: true, + relayResponseHeaders: headers({ + "x-relay-url": "https://relay-1.example", + "x-relay-mode": "primary", + "x-relay-attempts": "3", + "x-relay-fallback": "true", + }), + }); + assert.equal(r.success, true); + assert.deepEqual(r.relay, { + url: "https://relay-1.example", + mode: "primary", + attempts: 3, + fallback: true, + }); +}); + +test("omits relay block when no response headers provided", () => { + const r = buildRelayTestResult({ ...base, statusCode: 200, relayAuthPresent: true }); + assert.equal(r.success, true); + assert.equal(r.relay, undefined); +}); + +test("coerces missing/invalid awareness header values to null", () => { + const r = buildRelayTestResult({ + ...base, + statusCode: 200, + relayAuthPresent: true, + relayResponseHeaders: headers({ + "x-relay-attempts": "not-a-number", + "x-relay-fallback": "maybe", + }), + }); + assert.deepEqual(r.relay, { + url: null, + mode: null, + attempts: null, + fallback: false, + }); +}); + +test("does not parse awareness on a failed relay response", () => { + const r = buildRelayTestResult({ + ...base, + statusCode: 502, + publicIp: null, + relayAuthPresent: true, + relayResponseHeaders: headers({ "x-relay-url": "https://relay-1.example" }), + }); + assert.equal(r.success, false); + assert.equal(r.relay, undefined); + assert.ok(typeof r.error === "string" && r.error.includes("502")); +});