feat(proxy): relay repair + free-pool UX + relay awareness

This commit is contained in:
oyi77
2026-07-11 14:05:07 +07:00
parent 0a358c02ab
commit 6f9ce75f3c
28 changed files with 1438 additions and 70 deletions

View File

@@ -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.

View File

@@ -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": "<proxyId>" }` | `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.

View File

@@ -6,6 +6,7 @@
"ENVIRONMENT",
"FEATURE_FLAGS",
"FREE_TIERS",
"FREE_PROXIES_API",
"PROVIDER_REFERENCE"
]
}

View File

@@ -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<ProxyItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -169,6 +161,10 @@ export default function ProxyRegistryManager() {
const [healthById, setHealthById] = useState<Record<string, HealthInfo>>({});
const [testById, setTestById] = useState<Record<string, TestResult | null>>({});
const [testingId, setTestingId] = useState<string | null>(null);
const [repairingId, setRepairingId] = useState<string | null>(null);
const [repairErrorById, setRepairErrorById] = useState<Record<string, string>>({});
const [relayTested, setRelayTested] = useState<number | null>(null);
const [relayAlive, setRelayAlive] = useState<number | null>(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<string, unknown> = {
@@ -808,6 +855,11 @@ export default function ProxyRegistryManager() {
{error}
</div>
)}
{relayTested !== null && relayAlive !== null && (
<div className="mb-3 px-3 py-2 rounded border border-border/60 bg-surface-alt text-xs text-text-muted">
{t("relayProbeSummary", { tested: relayTested, alive: relayAlive })}
</div>
)}
{loading ? (
<div className="text-sm text-text-muted">{t("loading")}</div>
@@ -884,6 +936,33 @@ export default function ProxyRegistryManager() {
>
{t("test")}
</Button>
{item.relayInfo?.isRelay &&
(item.relayInfo.repairMode === "redeploy" ||
item.relayInfo.repairMode === "recovered") && (
<Button
size="sm"
variant="ghost"
icon="build"
onClick={() => void handleRepairRelay(item)}
loading={repairingId === item.id}
title={t("relayRepairTooltip")}
>
{t("repair")}
</Button>
)}
{item.relayInfo?.isRelay && item.relayInfo.authMissing && (
<span className="ml-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-400">
{t("relayAuthMissing")}
</span>
)}
{repairErrorById[item.id] && (
<span
className="ml-1 text-[10px] text-red-400"
title={repairErrorById[item.id]}
>
{t("relayRepairError")}
</span>
)}
<Button
size="sm"
variant="ghost"
@@ -1186,7 +1265,9 @@ export default function ProxyRegistryManager() {
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={poolStrategy}
onChange={(e) => handlePoolStrategyChange(e.target.value as PoolStrategy)}
onChange={(e) =>
handlePoolStrategyChange(e.target.value as "round-robin" | "random" | "sticky")
}
data-testid="proxy-registry-pool-strategy"
>
{POOL_STRATEGY_OPTIONS.map((opt) => (

View File

@@ -1,5 +1,5 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import SourceToggleBar, {
@@ -32,10 +32,14 @@ export default function FreePoolTab() {
const [bulkProgress, setBulkProgress] = useState<string | null>(null);
// #5595: per-source sync errors so a "Total: 0" result is never silent.
const [syncErrors, setSyncErrors] = useState<Record<string, string[]> | null>(null);
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<"quality" | "latency" | "recent">("quality");
const [loadingMore, setLoadingMore] = useState(false);
const [total, setTotal] = useState<number | null>(null);
const offsetRef = useRef(0);
// Load persisted disabled-sources from localStorage on mount
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage hydration, runs once
setDisabledSources(loadDisabledSources());
}, []);
@@ -49,37 +53,53 @@ export default function FreePoolTab() {
});
}, []);
const loadData = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s));
if (enabledSources.length < ALL_SOURCE_IDS.length) {
params.set("sources", enabledSources.join(","));
}
if (filterProtocol) params.set("protocol", filterProtocol);
if (filterCountry) params.set("country", filterCountry);
if (minQuality) params.set("minQuality", minQuality);
params.set("limit", "200");
const loadData = useCallback(
async (append = false) => {
if (append) setLoadingMore(true);
else setLoading(true);
try {
const params = new URLSearchParams();
const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s));
if (enabledSources.length < ALL_SOURCE_IDS.length) {
params.set("sources", enabledSources.join(","));
}
if (filterProtocol) params.set("protocol", filterProtocol);
if (filterCountry) params.set("country", filterCountry);
if (minQuality) params.set("minQuality", minQuality);
if (search.trim()) params.set("search", search.trim());
if (sortBy) params.set("sortBy", sortBy);
params.set("limit", "50");
params.set("offset", append ? String(offsetRef.current) : "0");
const [proxiesRes, statsRes] = await Promise.all([
fetch(`/api/settings/free-proxies?${params.toString()}`),
fetch("/api/settings/free-proxies/stats"),
]);
if (proxiesRes.ok) {
const data = await proxiesRes.json();
setProxies(data.items || []);
const [proxiesRes, statsRes] = await Promise.all([
fetch(`/api/settings/free-proxies?${params.toString()}`),
append ? null : fetch("/api/settings/free-proxies/stats"),
]);
if (proxiesRes.ok) {
const data = await proxiesRes.json();
const fetched: FreeProxyRowData[] = data.items || [];
setProxies((prev) => (append ? [...prev, ...fetched] : fetched));
setTotal(typeof data.total === "number" ? data.total : fetched.length);
if (append) offsetRef.current += fetched.length;
}
if (!append && statsRes?.ok) {
const data = await statsRes.json();
setStats(data.stats || null);
}
} catch {
// surface nothing on transient failure; table keeps last good data
} finally {
setLoading(false);
setLoadingMore(false);
}
if (statsRes.ok) {
const data = await statsRes.json();
setStats(data.stats || null);
}
} catch {}
setLoading(false);
}, [disabledSources, filterProtocol, filterCountry, minQuality]);
},
[disabledSources, filterProtocol, filterCountry, minQuality, search, sortBy]
);
const loadMore = useCallback(() => {
void loadData(true);
}, [loadData]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetch on filter change
loadData();
}, [loadData]);
@@ -165,9 +185,36 @@ export default function FreePoolTab() {
<div className="flex flex-wrap items-center gap-3">
<SourceToggleBar disabledSources={disabledSources} onToggle={handleToggleSource} />
<div className="flex gap-2 ml-auto flex-wrap items-center">
<input
type="text"
placeholder={t("proxyFreePoolSearchPlaceholder")}
value={search}
onChange={(e) => {
offsetRef.current = 0;
setSearch(e.target.value);
}}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-40"
aria-label={t("proxyFreePoolSearchLabel")}
/>
<select
value={sortBy}
onChange={(e) => {
offsetRef.current = 0;
setSortBy(e.target.value as "quality" | "latency" | "recent");
}}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1"
aria-label={t("proxyFreePoolSortLabel")}
>
<option value="quality">{t("proxyFreePoolSortQuality")}</option>
<option value="latency">{t("proxyFreePoolSortLatency")}</option>
<option value="recent">{t("proxyFreePoolSortRecent")}</option>
</select>
<select
value={filterProtocol}
onChange={(e) => setFilterProtocol(e.target.value)}
onChange={(e) => {
offsetRef.current = 0;
setFilterProtocol(e.target.value);
}}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1"
aria-label={t("proxyFreePoolFilterProtocol")}
>
@@ -182,7 +229,10 @@ export default function FreePoolTab() {
type="text"
placeholder={t("proxyFreePoolCountryPlaceholder")}
value={filterCountry}
onChange={(e) => setFilterCountry(e.target.value.toUpperCase().slice(0, 2))}
onChange={(e) => {
offsetRef.current = 0;
setFilterCountry(e.target.value.toUpperCase().slice(0, 2));
}}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-28"
aria-label={t("proxyFreePoolFilterCountry")}
/>
@@ -190,7 +240,10 @@ export default function FreePoolTab() {
type="number"
placeholder={t("proxyFreePoolMinQualityPlaceholder")}
value={minQuality}
onChange={(e) => setMinQuality(e.target.value)}
onChange={(e) => {
offsetRef.current = 0;
setMinQuality(e.target.value);
}}
min={0}
max={100}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-24"
@@ -207,6 +260,11 @@ export default function FreePoolTab() {
<span>
{t("proxyFreePoolTotal")}: {stats.total}
</span>
{total != null && (
<span>
{t("proxyFreePoolListTotal")}: {total}
</span>
)}
<span>
{t("proxyFreePoolInPool")}: {stats.inPool}
</span>
@@ -223,6 +281,12 @@ export default function FreePoolTab() {
</div>
)}
{loading && (
<div className="text-xs text-text-muted" data-testid="free-pool-loading">
{t("loading")}
</div>
)}
{syncErrors && (
<div
className="text-xs text-red-500 flex flex-col gap-1"
@@ -258,6 +322,13 @@ export default function FreePoolTab() {
</Button>
</div>
)}
{!loading && total != null && proxies.length < total && (
<div className="flex justify-center pt-1">
<Button size="sm" variant="secondary" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? t("loading") : t("proxyFreePoolLoadMore")}
</Button>
</div>
)}
<div className="overflow-x-auto rounded border border-border bg-surface">
<table className="w-full text-sm">

View File

@@ -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 (
<div className="space-y-4">
@@ -116,7 +122,7 @@ export default function ProxyPoolTab() {
</div>
</div>
)}
<ProxyRegistryManager />
<ProxyRegistryManager onRedeployRelay={handleRedeployRelay} />
<VercelRelayModal
isOpen={vercelModalOpen}
onClose={() => setVercelModalOpen(false)}

View File

@@ -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;
}

View File

@@ -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",
});
}

View File

@@ -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");
}

View File

@@ -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]);
}
}

View File

@@ -0,0 +1,98 @@
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;
try {
const parsed = JSON.parse(proxy.notes ?? "{}") as { relayAuthEnc?: string };
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",
});
}
await updateProxy(id, { notes: JSON.stringify({ relayAuth: decrypted }) });
return Response.json({ repaired: true, mode: "recovered" });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to repair relay");
}
}

View File

@@ -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()

View File

@@ -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",
};
}

View File

@@ -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) {

View File

@@ -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|…) or auth-less shorthand (HOST:PORT). Existing proxies (same host + port) will be updated.",

View File

@@ -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<string, string[]>;
type DbRow = Record<string, unknown>;
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<FreeProxyRecord[]> {
@@ -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<number> {
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<string> {
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<FreeProxyStats> {
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<void> {
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<void> {
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<FreeProxySyncErrors> {
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;
}

View File

@@ -0,0 +1,10 @@
-- 119_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'))
);

View File

@@ -74,6 +74,53 @@ export function extractRelayAuth(notes: unknown): string | undefined {
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);

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -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"),
});

View File

@@ -0,0 +1,91 @@
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 listRoute = await import("../../../src/app/api/settings/free-proxies/route.ts");
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 });
});
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();
const res = await listRoute.GET(new Request("http://localhost/api/settings/free-proxies"));
assert.equal(res.status, 401);
});

View File

@@ -0,0 +1,94 @@
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 listRoute = await import("../../../src/app/api/settings/free-proxies/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 });
});
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<string, string[]>;
};
};
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 () => {
const res = await listRoute.GET(new Request("http://localhost/api/settings/free-proxies"));
assert.equal(res.status, 401);
});

View File

@@ -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<string> {
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<string> {
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)}`
);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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 });
});

View File

@@ -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<string, string>): { 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"));
});