mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(proxy): relay repair + free-pool UX + relay awareness
This commit is contained in:
77
docs/reference/FREE_PROXIES_API.md
Normal file
77
docs/reference/FREE_PROXIES_API.md
Normal 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.
|
||||
93
docs/reference/RELAY_TROUBLESHOOTING.md
Normal file
93
docs/reference/RELAY_TROUBLESHOOTING.md
Normal 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.
|
||||
@@ -6,6 +6,7 @@
|
||||
"ENVIRONMENT",
|
||||
"FEATURE_FLAGS",
|
||||
"FREE_TIERS",
|
||||
"FREE_PROXIES_API",
|
||||
"PROVIDER_REFERENCE"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,8 +53,10 @@ export default function FreePoolTab() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
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));
|
||||
@@ -60,26 +66,40 @@ export default function FreePoolTab() {
|
||||
if (filterProtocol) params.set("protocol", filterProtocol);
|
||||
if (filterCountry) params.set("country", filterCountry);
|
||||
if (minQuality) params.set("minQuality", minQuality);
|
||||
params.set("limit", "200");
|
||||
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"),
|
||||
append ? null : fetch("/api/settings/free-proxies/stats"),
|
||||
]);
|
||||
if (proxiesRes.ok) {
|
||||
const data = await proxiesRes.json();
|
||||
setProxies(data.items || []);
|
||||
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 (statsRes.ok) {
|
||||
if (!append && statsRes?.ok) {
|
||||
const data = await statsRes.json();
|
||||
setStats(data.stats || null);
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// surface nothing on transient failure; table keeps last good data
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}, [disabledSources, filterProtocol, filterCountry, minQuality]);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
98
src/app/api/settings/proxies/[id]/repair-relay/route.ts
Normal file
98
src/app/api/settings/proxies/[id]/repair-relay/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
10
src/lib/db/migrations/119_free_proxy_sync_errors.sql
Normal file
10
src/lib/db/migrations/119_free_proxy_sync_errors.sql
Normal 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'))
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
28
src/lib/db/relayProbeStats.ts
Normal file
28
src/lib/db/relayProbeStats.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
|
||||
91
tests/unit/api/free-proxies-list-route.test.ts
Normal file
91
tests/unit/api/free-proxies-list-route.test.ts
Normal 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);
|
||||
});
|
||||
94
tests/unit/api/free-proxies-route.test.ts
Normal file
94
tests/unit/api/free-proxies-route.test.ts
Normal 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);
|
||||
});
|
||||
130
tests/unit/api/proxies-repair-relay.test.ts
Normal file
130
tests/unit/api/proxies-repair-relay.test.ts
Normal 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)}`
|
||||
);
|
||||
});
|
||||
97
tests/unit/free-proxies-list-search.test.ts
Normal file
97
tests/unit/free-proxies-list-search.test.ts
Normal 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);
|
||||
});
|
||||
83
tests/unit/proxy-relay-mappers.test.ts
Normal file
83
tests/unit/proxy-relay-mappers.test.ts
Normal 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);
|
||||
});
|
||||
36
tests/unit/relay-probe-stats.test.ts
Normal file
36
tests/unit/relay-probe-stats.test.ts
Normal 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 });
|
||||
});
|
||||
68
tests/unit/relay-test-result-awareness.test.ts
Normal file
68
tests/unit/relay-test-result-awareness.test.ts
Normal 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"));
|
||||
});
|
||||
Reference in New Issue
Block a user