fix(proxy): relay repair + free-pool UX + relay awareness (#6909)

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

* fix(proxy): preserve existing notes fields on relay repair; fix cpu limit /1000 across all 5 container providers

* feat(proxy): extract bulk-import and pool-modal hooks (#6625)

* fix: prevent relay type normalization to http on PATCH

Bug: updateProxyRegistrySchema inherited .default("http") from the base
schema, causing PATCH to silently overwrite relay types (vercel/deno/cloudflare)
with "http" when the client didn't send a type field.

- Move .default('http') from proxyRegistryFieldsSchema to
  createProxyRegistrySchema (only applies to new proxies)
- Strip undefined keys from validated changes before passing to
  updateProxy — .partial() leaves absent fields as undefined, which
  the spread merge in updateProxyRow would propagate to the DB
- Add console.warn in extractRelayAuth when decrypt fails on a
  known-encrypted relayAuthEnc blob

Closes #6905

* feat: replace Load More with page-number pagination in FreePoolTab

- Adds page-number pagination controls with prev/next buttons
- Shows per-page summary with total counts
- Resets to page 1 on filter change via wrapper setters

* fix(proxy): restore free-proxy sync-error tracking reverted by pagination commit

commit 2c8e79f13 (page-number pagination for FreePoolTab) accidentally
reverted the recordFreeProxySyncErrors/clearFreeProxySyncErrors/
getFreeProxySyncErrors functions and the search/sortBy list options that
an earlier commit (6f9ce75f3) in this same PR had added to
src/lib/db/freeProxies.ts and src/app/api/settings/free-proxies/route.ts.

localDb.ts still re-exported the three sync-error functions, so every
module that transitively imports it (which is most of the unit test
suite, plus the Next.js build used by dast-smoke) failed at load time
with "The requested module './db/freeProxies' does not provide an
export named 'clearFreeProxySyncErrors'".

Restores the reverted implementation (verbatim) and fixes the two new
"requires management auth" tests, which asserted 401 for an
unauthenticated request without configuring INITIAL_PASSWORD +
requireLogin first — on a fresh DB with no password configured,
isAuthRequired() treats a loopback request as the pre-setup bootstrap
path and allows it through, so the assertion needs the same
password/requireLogin setup already used by
tests/unit/api/settings-audit.test.ts.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* fix(proxy): trim unrelated scope from relay-repair/free-pool PR

Remove two subsystems that are unrelated to relay repair and the
free-pool UX and were never wired into the app:

- open-sse/services/combo/capabilityRequirements.ts and
  CapabilityRequirementsEditor.tsx: a combo capability-filtering
  feature never imported by combo.ts or combos/page.tsx, with no
  test coverage.
- src/lib/skills/containerProvider.ts CPU-limit rescale: an
  untested change to sandbox resource limits, unrelated to the
  proxy/relay subsystem this PR targets. Restored to the
  release baseline.

Also renumber the free_proxy_sync_errors migration 121 -> 122 to
avoid colliding with #6855, which independently claims 121 on the
same release branch.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Paijo
2026-07-12 12:28:26 +07:00
committed by GitHub
parent 10807972db
commit b5e75bb8fe
32 changed files with 1788 additions and 92 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";
@@ -10,20 +11,7 @@ import { ProxyBatchActions } from "./ProxyBatchActions";
import { ProxyCheckboxCell } from "./ProxyCheckboxCell";
import { parseBulkImportText, type ParsedProxyEntry, type ParseError } from "./parseBulkProxyImport";
import { POOL_STRATEGY_OPTIONS, isPoolStrategy, type PoolStrategy } from "./proxyStrategyOptions";
type ProxyItem = {
id: string;
name: string;
type: string;
host: string;
port: number;
username?: string | null;
password?: string | null;
region?: string | null;
notes?: string | null;
status?: string;
family?: string;
};
import type { ProxyItem } from "./proxyRegistryTypes";
type UsageInfo = {
count: number;
@@ -103,7 +91,11 @@ const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
#`;
export default function ProxyRegistryManager() {
export default function ProxyRegistryManager({
onRedeployRelay,
}: {
onRedeployRelay?: (proxy: ProxyItem) => void;
} = {}) {
const t = useTranslations("proxyRegistry");
const [items, setItems] = useState<ProxyItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -117,6 +109,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);
@@ -133,22 +129,6 @@ export default function ProxyRegistryManager() {
const [poolMembers, setPoolMembers] = useState<string[]>([]);
const [poolAddProxyId, setPoolAddProxyId] = useState("");
const [poolLoading, setPoolLoading] = useState(false);
const [poolSaving, setPoolSaving] = useState(false);
const [poolLoaded, setPoolLoaded] = useState(false);
// Bulk Import state
const [bulkImportOpen, setBulkImportOpen] = useState(false);
const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE);
const [bulkImportParsed, setBulkImportParsed] = useState<ParsedProxyEntry[]>([]);
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
const [bulkImportSkipped, setBulkImportSkipped] = useState(0);
const [bulkImportParsedOnce, setBulkImportParsedOnce] = useState(false);
const [bulkImporting, setBulkImporting] = useState(false);
const [bulkImportResult, setBulkImportResult] = useState<{
created: number;
updated: number;
failed: number;
} | null>(null);
const editingId = useMemo(() => form.id || "", [form.id]);
@@ -208,6 +188,11 @@ export default function ProxyRegistryManager() {
setItems([]);
return;
}
const stats = data?.relayProbeStats;
if (stats && typeof stats.tested === "number" && typeof stats.alive === "number") {
setRelayTested(stats.tested);
setRelayAlive(stats.alive);
}
const loaded: ProxyItem[] = Array.isArray(data?.items) ? data.items : [];
setItems(loaded);
const ids = loaded.map((p) => p.id).filter(Boolean);
@@ -343,6 +328,51 @@ export default function ProxyRegistryManager() {
}
};
const repairRelayResponseSchema = z.object({
repaired: z.boolean().optional(),
mode: z.enum(["noop", "recovered", "redeploy"]).optional(),
error: z.object({ message: z.string() }).optional(),
});
const handleRepairRelay = async (item: ProxyItem) => {
if (repairingId || !item.relayInfo?.isRelay) return;
setRepairingId(item.id);
setRepairErrorById((prev) => {
const next = { ...prev };
delete next[item.id];
return next;
});
try {
const res = await fetch(`/api/settings/proxies/${item.id}/repair-relay`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: item.id }),
});
const parsed = repairRelayResponseSchema.safeParse(await res.json());
const data = parsed.success ? parsed.data : {};
if (!res.ok) {
if (res.status === 409 && onRedeployRelay) {
onRedeployRelay(item);
return;
}
const message =
res.status === 409
? t("relayRepairRedeployRequired")
: data.error?.message || t("relayRepairFailed");
setRepairErrorById((prev) => ({ ...prev, [item.id]: message }));
return;
}
if (data.repaired) {
await load();
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : t("relayRepairFailed");
setRepairErrorById((prev) => ({ ...prev, [item.id]: message }));
} finally {
setRepairingId(null);
}
};
const handleSave = async () => {
if (!(form.name || "").trim() || !(form.host || "").trim()) {
setError(t("errorNameHostRequired"));
@@ -353,6 +383,7 @@ export default function ProxyRegistryManager() {
setError(null);
const normalizedUsername = (form.username || "").trim();
const normalizedPassword = (form.password || "").trim();
const payload: Record<string, unknown> = {
@@ -756,6 +787,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>
@@ -832,6 +868,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"
@@ -1134,7 +1197,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

@@ -17,14 +17,17 @@ type FreePoolStats = {
lastSyncAt: string | null;
};
const PER_PAGE = 50;
const MAX_VISIBLE_PAGES = 7;
export default function FreePoolTab() {
const t = useTranslations("settings");
const [proxies, setProxies] = useState<FreeProxyRowData[]>([]);
const [stats, setStats] = useState<FreePoolStats | null>(null);
const [disabledSources, setDisabledSources] = useState<Set<SourceId>>(new Set());
const [filterProtocol, setFilterProtocol] = useState("");
const [filterCountry, setFilterCountry] = useState("");
const [minQuality, setMinQuality] = useState("");
const [filterProtocol, setFilterProtocolRaw] = useState("");
const [filterCountry, setFilterCountryRaw] = useState("");
const [minQuality, setMinQualityRaw] = useState("");
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -32,22 +35,29 @@ 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);
// Pagination state
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
// Load persisted disabled-sources from localStorage on mount
useEffect(() => {
const saved = loadDisabledSources();
// eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage hydration, runs once
setDisabledSources(loadDisabledSources());
}, []);
const handleToggleSource = useCallback((source: SourceId) => {
setDisabledSources((prev) => {
const next = new Set(prev);
if (next.has(source)) next.delete(source);
else next.add(source);
saveDisabledSources(next);
return next;
});
if (saved) setDisabledSources(saved);
}, []);
// Wrapper setters that also reset page to 1
const setFilterProtocol = (v: string) => {
setFilterProtocolRaw(v);
setPage(1);
};
const setFilterCountry = (v: string) => {
setFilterCountryRaw(v);
setPage(1);
};
const setMinQuality = (v: string) => {
setMinQualityRaw(v);
setPage(1);
};
const loadData = useCallback(async () => {
setLoading(true);
@@ -60,7 +70,8 @@ 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");
params.set("limit", String(PER_PAGE));
params.set("offset", String((page - 1) * PER_PAGE));
const [proxiesRes, statsRes] = await Promise.all([
fetch(`/api/settings/free-proxies?${params.toString()}`),
@@ -69,6 +80,7 @@ export default function FreePoolTab() {
if (proxiesRes.ok) {
const data = await proxiesRes.json();
setProxies(data.items || []);
setTotal(data.total ?? 0);
}
if (statsRes.ok) {
const data = await statsRes.json();
@@ -76,7 +88,7 @@ export default function FreePoolTab() {
}
} catch {}
setLoading(false);
}, [disabledSources, filterProtocol, filterCountry, minQuality]);
}, [disabledSources, filterProtocol, filterCountry, minQuality, page]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetch on filter change
@@ -94,17 +106,9 @@ export default function FreePoolTab() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
// #5595: surface per-source errors the route already returns so a
// partial/empty sync explains itself instead of silently showing "Total: 0".
const data = await res.json().catch(() => null);
if (data?.results) {
const errs: Record<string, string[]> = {};
for (const [src, r] of Object.entries(
data.results as Record<string, { errors?: string[] }>
)) {
if (Array.isArray(r?.errors) && r.errors.length > 0) errs[src] = r.errors;
}
if (Object.keys(errs).length > 0) setSyncErrors(errs);
const data = await res.json();
if (!res.ok) {
setSyncErrors(data.errors ?? null);
}
await loadData();
} catch {}
@@ -112,17 +116,20 @@ export default function FreePoolTab() {
};
const handleAddToPool = async (id: string) => {
setAddingIds((prev) => new Set(prev).add(id));
setAddingIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
try {
const res = await fetch(`/api/settings/free-proxies/${id}/add-to-pool`, {
method: "POST",
});
// #4878: gate on the parsed body, not just res.ok. The route used to return
// a default 200 with { success:false } on a failed connectivity probe, which
// flipped the row to "In Pool" optimistically even though nothing was added.
const data = await res.json().catch(() => null);
if (res.ok && data?.success) {
setProxies((prev) => prev.map((p) => (p.id === id ? { ...p, inPool: true } : p)));
if (res.ok) {
const body = await res.json();
if (body.ok !== true) return;
// Optimistic: remove from local list since it's now in pool
setProxies((prev) => prev.filter((p) => p.id !== id));
}
} catch {}
setAddingIds((prev) => {
@@ -159,6 +166,42 @@ export default function FreePoolTab() {
};
const notInPoolProxies = proxies.filter((p) => !p.inPool);
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
// Build visible page range around current page
const buildPageRange = (): (number | "ellipsis")[] => {
if (totalPages <= MAX_VISIBLE_PAGES) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | "ellipsis")[] = [];
const half = Math.floor(MAX_VISIBLE_PAGES / 2);
let start = Math.max(1, page - half);
let end = Math.min(totalPages, page + half);
// Adjust if near start/end
if (page - half < 1) {
end = Math.min(totalPages, MAX_VISIBLE_PAGES);
}
if (page + half > totalPages) {
start = Math.max(1, totalPages - MAX_VISIBLE_PAGES + 1);
}
if (start > 1) {
pages.push(1);
if (start > 2) pages.push("ellipsis");
}
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages) {
if (end < totalPages - 1) pages.push("ellipsis");
pages.push(totalPages);
}
return pages;
};
const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages) return;
setPage(newPage);
};
return (
<div className="space-y-4">
@@ -313,6 +356,55 @@ export default function FreePoolTab() {
</tbody>
</table>
</div>
{/* Page-number pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-1 pt-2">
<button
type="button"
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
aria-label="Previous page"
>
&laquo;
</button>
{buildPageRange().map((p, i) =>
p === "ellipsis" ? (
<span key={`ellipsis-${i}`} className="px-1 text-text-muted text-xs">
...
</span>
) : (
<button
key={p}
type="button"
onClick={() => handlePageChange(p)}
className={`px-2.5 py-1 text-xs rounded ${
p === page
? "bg-primary text-white font-medium"
: "hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{p}
</button>
)
)}
<button
type="button"
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
aria-label="Next page"
>
&raquo;
</button>
</div>
)}
{/* Per-page summary */}
<div className="text-center text-xs text-text-muted">
{total > 0
? `Page ${page} of ${totalPages} (${total} total proxies)`
: `${total} total proxies`}
</div>
</div>
);
}

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

@@ -0,0 +1,176 @@
"use client";
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
type ParsedProxyEntry = {
name: string;
host: string;
port: number;
username: string;
password: string;
type: string;
region: string;
status: string;
notes: string;
};
type ParseError = {
line: number;
reason: string;
};
type BulkImportResult = {
created: number;
updated: number;
failed: number;
};
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
# Format: NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
# Required: NAME, HOST, PORT
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
#
# SOCKS5 examples:
# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy
# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West
#
# HTTP/HTTPS examples:
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
# https-proxy|proxy.example.com|443|admin|secret123|https|US|active
`;
const VALID_TYPES = new Set(["http", "https", "socks5"]);
const VALID_STATUSES = new Set(["active", "inactive"]);
export function parseBulkImportText(text: string): {
entries: ParsedProxyEntry[];
errors: ParseError[];
skipped: number;
} {
const lines = text.split("\n");
const entries: ParsedProxyEntry[] = [];
const errors: ParseError[] = [];
let skipped = 0;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i].trim();
if (!raw || raw.startsWith("#")) {
skipped++;
continue;
}
const parts = raw.split("|").map((p) => p.trim());
const [name, host, portStr, username, password, type, region, status, notes] = parts;
const lineNum = i + 1;
if (!name) {
errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" });
continue;
}
if (!host) {
errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" });
continue;
}
const port = Number(portStr);
if (!portStr || isNaN(port) || port < 1 || port > 65535) {
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" });
continue;
}
const normalizedType = (type || "socks5").toLowerCase();
if (!VALID_TYPES.has(normalizedType)) {
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" });
continue;
}
const normalizedStatus = (status || "active").toLowerCase();
if (!VALID_STATUSES.has(normalizedStatus)) {
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" });
continue;
}
entries.push({
name,
host,
port,
username: username || "",
password: password || "",
type: normalizedType,
region: region || "",
status: normalizedStatus,
notes: notes || "",
});
}
return { entries, errors, skipped };
}
interface UseProxyBulkImportOptions {
onImport: (entries: ParsedProxyEntry[]) => Promise<BulkImportResult>;
}
export function useProxyBulkImport({ onImport }: UseProxyBulkImportOptions) {
const t = useTranslations("proxyRegistry");
const [open, setOpen] = useState(false);
const [text, setText] = useState(BULK_IMPORT_TEMPLATE);
const [parsed, setParsed] = useState<ParsedProxyEntry[]>([]);
const [errors, setErrors] = useState<ParseError[]>([]);
const [skipped, setSkipped] = useState(0);
const [parsedOnce, setParsedOnce] = useState(false);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<BulkImportResult | null>(null);
const handleParse = useCallback(() => {
const { entries, errors: parseErrors, skipped: skippedLines } = parseBulkImportText(text);
setParsed(entries);
setErrors(parseErrors);
setSkipped(skippedLines);
setParsedOnce(true);
setResult(null);
}, [text]);
const handleImport = useCallback(async () => {
if (parsed.length === 0) return;
setImporting(true);
try {
const res = await onImport(parsed);
setResult(res);
if (res.failed === 0) {
setText(BULK_IMPORT_TEMPLATE);
setParsed([]);
setErrors([]);
setSkipped(0);
setParsedOnce(false);
}
} finally {
setImporting(false);
}
}, [parsed, onImport]);
const handleClose = useCallback(() => {
setOpen(false);
setText(BULK_IMPORT_TEMPLATE);
setParsed([]);
setErrors([]);
setSkipped(0);
setParsedOnce(false);
setResult(null);
}, []);
return {
open,
setOpen,
text,
setText,
parsed,
errors,
skipped,
parsedOnce,
importing,
result,
t,
handleParse,
handleImport,
handleClose,
};
}

View File

@@ -0,0 +1,96 @@
"use client";
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
interface UseProxyPoolModalOptions {
items: { id: string; name: string }[];
onSave: (
scope: string,
scopeIds: string,
proxyId: string,
strategy: "round-robin" | "random" | "sticky"
) => Promise<void>;
onLoad: (
scope: string,
scopeIds: string,
proxyId: string
) => Promise<{ members: string[]; strategy: string } | null>;
}
export function useProxyPoolModal({ items, onSave, onLoad }: UseProxyPoolModalOptions) {
const t = useTranslations("proxyRegistry");
const [open, setOpen] = useState(false);
const [scope, setScope] = useState<"global" | "provider">("provider");
const [scopeIds, setScopeIds] = useState("");
const [proxyId, setProxyId] = useState("");
const [strategy, setStrategy] = useState<"round-robin" | "random" | "sticky">("round-robin");
const [members, setMembers] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const handleLoad = useCallback(async () => {
if (!proxyId) return;
setLoading(true);
setError(null);
try {
const data = await onLoad(scope, scopeIds, proxyId);
if (data) {
setMembers(data.members);
setStrategy(data.strategy as "round-robin" | "random" | "sticky");
setLoaded(true);
}
} catch (e: any) {
setError(e?.message || t("poolLoadFailed"));
} finally {
setLoading(false);
}
}, [scope, scopeIds, proxyId, onLoad, t]);
const handleSave = useCallback(async () => {
if (!proxyId) return;
setLoading(true);
setError(null);
try {
await onSave(scope, scopeIds, proxyId, strategy);
setOpen(false);
} catch (e: any) {
setError(e?.message || t("poolSaveFailed"));
} finally {
setLoading(false);
}
}, [scope, scopeIds, proxyId, strategy, onSave, t]);
const handleClose = useCallback(() => {
setOpen(false);
setScope("provider");
setScopeIds("");
setProxyId("");
setStrategy("round-robin");
setMembers([]);
setLoaded(false);
setError(null);
}, []);
return {
open,
setOpen,
scope,
setScope,
scopeIds,
setScopeIds,
proxyId,
setProxyId,
strategy,
setStrategy,
members,
loading,
error,
loaded,
handleLoad,
handleSave,
handleClose,
t,
};
}

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,102 @@
import { z } from "zod";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { getProxyById, updateProxy } from "@/lib/localDb";
import { decrypt } from "@/lib/db/encryption";
import { isRelayProxyType, relayRepairMode } from "@/lib/db/proxies/mappers";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const idParamSchema = z.object({ id: z.string().min(1) });
/**
* POST /api/settings/proxies/[id]/repair-relay
*
* Recovers a relay's auth IN PLACE without a full redeploy. The deploy token is
* never persisted, but when STORAGE_ENCRYPTION_KEY is set the deploy routes
* wrote an encrypted `relayAuthEnc` blob into proxy `notes`. That blob is the
* recoverable secret — we decrypt it and write the plaintext `relayAuth` back,
* so the relay works again without re-entering any deploy credentials.
*
* Returns:
* 200 { repaired: false, mode: "noop" } — plaintext already present
* 200 { repaired: true, mode: "recovered" } — re-derived from relayAuthEnc
* 409 { repaired: false, mode: "redeploy" } — token unrecoverable, redeploy
* 400 — not a relay-type proxy
* 404 — proxy not found
*/
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> | { id: string } }
) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const params = await context.params;
const idValidation = validateBody(idParamSchema, params);
if (isValidationFailure(idValidation)) {
return createErrorResponse({
status: 400,
message: idValidation.error.message,
type: "invalid_request",
});
}
const { id } = idValidation.data;
try {
const proxy = await getProxyById(id, { includeSecrets: true });
if (!proxy) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
if (!isRelayProxyType(proxy.type)) {
return createErrorResponse({
status: 400,
message: "Repair is only available for relay proxies (vercel/deno/cloudflare)",
type: "invalid_request",
});
}
const mode = relayRepairMode(proxy.notes, proxy.type);
if (mode === "noop") {
return Response.json({ repaired: false, mode });
}
if (mode === "redeploy") {
return createErrorResponse({
status: 409,
message:
"Relay auth is unrecoverable (no stored token, encrypted blob absent or undecryptable — " +
"likely a STORAGE_ENCRYPTION_KEY rotation). Redeploy the relay to write a fresh relayAuth.",
type: "conflict",
});
}
// mode === "recovered": read the still-encrypted blob, decrypt, write plaintext.
let enc: string | undefined;
let existingNotes: Record<string, unknown> = {};
try {
const parsed = JSON.parse(proxy.notes ?? "{}") as Record<string, unknown>;
existingNotes = parsed;
if (typeof parsed.relayAuthEnc === "string") enc = parsed.relayAuthEnc;
} catch {
enc = undefined;
}
const decrypted = typeof enc === "string" ? decrypt(enc) : undefined;
if (!decrypted) {
return createErrorResponse({
status: 409,
message:
"Relay auth is unrecoverable (encrypted blob could not be decrypted — likely a " +
"STORAGE_ENCRYPTION_KEY rotation). Redeploy the relay to write a fresh relayAuth.",
type: "conflict",
});
}
// Merge relayAuth into existing notes; drop relayAuthEnc since plaintext is now present.
const { relayAuthEnc: _dropped, ...rest } = existingNotes as { relayAuthEnc?: unknown };
await updateProxy(id, { notes: JSON.stringify({ ...rest, relayAuth: decrypted }) });
return Response.json({ repaired: true, mode: "recovered" });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to repair relay");
}
}

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|…), shorthand (ip:port, ip:port:user:pass, user:pass@ip:port, user:pass:ip:port, protocol://ip:port, protocol://user:pass@ip:port), or protocol header mode (bare protocol on first line applies to subsequent shorthand lines). Existing proxies (same host + port) will be updated.",

View File

@@ -94,7 +94,13 @@ export async function handleProxyUpdate(request: Request) {
});
}
const { id, assignment, ...changes } = validation.data;
const { id, assignment, ...rawChanges } = validation.data;
// Strip keys the client didn't send — .partial() resolves absent fields to
// undefined, which would silently overwrite DB values via the spread merge
// in updateProxyRow. Only include keys the client explicitly provided.
const changes = Object.fromEntries(
Object.entries(rawChanges).filter(([_, v]) => v !== undefined)
);
if (assignment) {
const result = await updateProxyAndAssign(id, changes, assignment);
if (!result?.proxy) {

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 @@
-- 122_free_proxy_sync_errors.sql
-- Per-source sync errors for the free-proxy pool. Each failed source writes its
-- last error(s) here, keyed by source id, so a "Total: 0" result is honest
-- instead of silent. Cleared on a successful sync for that source.
CREATE TABLE IF NOT EXISTS free_proxy_sync_errors (
source TEXT PRIMARY KEY,
errors TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -1,4 +1,4 @@
import { decrypt } from "../encryption";
import { decrypt, looksEncrypted } from "../encryption";
import type {
JsonRecord,
ProxyScope,
@@ -68,12 +68,68 @@ export function extractRelayAuth(notes: unknown): string | undefined {
if (parsed.relayAuthEnc) {
const dec = decrypt(parsed.relayAuthEnc);
if (dec) return dec;
// decrypt returned null despite a present blob — warn so operators
// know the key changed or went missing. The plaintext fallback below
// may still save us (legacy rows that never migrated).
if (looksEncrypted(parsed.relayAuthEnc)) {
console.warn(
`[relay] Failed to decrypt relayAuthEnc for proxy — ` +
`STORAGE_ENCRYPTION_KEY may have changed or been unset`
);
}
}
return parsed.relayAuth || undefined;
} catch {
return undefined;
}
}
/**
* True when a relay-type proxy has no usable auth in `notes` — neither a
* plaintext `relayAuth` nor a still-decryptable `relayAuthEnc` blob. This is
* the state that makes proxyFetch throw `missing relayAuth` at request time.
* Non-relay types are never "missing" (they have no relay auth to begin with).
*/
export function isRelayAuthMissing(notes: unknown, type: unknown): boolean {
return isRelayProxyType(type) && extractRelayAuth(notes) === undefined;
}
export type RelayRepairMode = "noop" | "recovered" | "redeploy" | null;
/**
* Classifies how a relay's missing/uncertain auth can be fixed WITHOUT a full
* redeploy (the deploy token is never persisted):
* - "noop": plaintext relayAuth already present — nothing to do.
* - "recovered": relayAuthEnc blob present and decrypts — re-derive plaintext
* in place (key still set, blob intact).
* - "redeploy": neither recoverable — token is unrecoverable, must redeploy.
* - null: not a relay type (repair is N/A).
*/
export function relayRepairMode(notes: unknown, type: unknown): RelayRepairMode {
if (!isRelayProxyType(type)) return null;
// Plaintext relayAuth already in place: nothing to do.
const parsed = safeParseNotes(notes);
if (typeof parsed?.relayAuth === "string" && parsed.relayAuth) return "noop";
// Encrypted blob present and still decryptable: re-derive plaintext in place.
if (
typeof parsed?.relayAuthEnc === "string" &&
parsed.relayAuthEnc &&
decrypt(parsed.relayAuthEnc)
) {
return "recovered";
}
// Neither recoverable: the deploy token (never persisted) is lost → redeploy.
return "redeploy";
}
function safeParseNotes(notes: unknown): { relayAuth?: string; relayAuthEnc?: string } | null {
if (typeof notes !== "string") return null;
try {
const parsed = JSON.parse(notes) as { relayAuth?: string; relayAuthEnc?: string };
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
export function toRegistryProxyResolution(row: unknown, level: ProxyScope, levelId: string | null) {
const record = toRecord(row);

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

@@ -108,8 +108,7 @@ export const proxyRegistryFieldsSchema = z
(value) => (typeof value === "string" ? value.trim().toLowerCase() : value),
z.enum(["http", "https", "socks5", "vercel", "deno", "cloudflare"])
)
.optional()
.default("http"),
.optional(),
host: z.string().trim().min(1, "host is required").max(255),
port: z.coerce.number().int().min(1).max(65535),
username: z.string().optional(),
@@ -135,6 +134,13 @@ export const proxyRegistryFieldsSchema = z
export const createProxyRegistrySchema = proxyRegistryFieldsSchema
.extend({
type: z
.preprocess(
(value) => (typeof value === "string" ? value.trim().toLowerCase() : value),
z.enum(["http", "https", "socks5", "vercel", "deno", "cloudflare"])
)
.optional()
.default("http"),
assignment: inlineProxyAssignmentSchema.optional(),
})
.strict();

View File

@@ -0,0 +1,104 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { FreeProxyItem } from "@/lib/freeProxyProviders/types";
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
// DATA_DIR must be set before the DB core module evaluates its singleton.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-list-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../../src/lib/db/core.ts");
const freeProxiesDb = await import("../../../src/lib/db/freeProxies.ts");
const settingsDb = await import("../../../src/lib/db/settings.ts");
const listRoute = await import("../../../src/app/api/settings/free-proxies/route.ts");
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
async function reset() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
function make(host: string, quality: number, latency: number): FreeProxyItem {
return {
source: "1proxy",
host,
port: 8080,
type: "http",
countryCode: "US",
qualityScore: quality,
latencyMs: latency,
anonymity: "elite",
lastValidated: "2026-01-01T00:00:00.000Z",
};
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
});
test("GET returns the full contract { proxies, total, hasMore, stats, syncErrors }", async () => {
await reset();
await freeProxiesDb.upsertFreeProxy(make("alpha.example", 90, 100));
await freeProxiesDb.upsertFreeProxy(make("bravo.example", 40, 300));
const res = await listRoute.GET(
await makeManagementSessionRequest("http://localhost/api/settings/free-proxies?sortBy=quality")
);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.success, true);
const { proxies, total, stats, syncErrors } = body.data;
assert.equal(total, 2);
assert.equal(proxies.length, 2);
assert.equal(stats.total, 2);
assert.deepEqual(syncErrors, {});
// quality sort → first item is the highest score.
assert.equal(proxies[0].qualityScore, 90);
});
test("GET search param filters the returned proxies and total", async () => {
await reset();
await freeProxiesDb.upsertFreeProxy(make("alpha.example", 90, 100));
await freeProxiesDb.upsertFreeProxy(make("bravo.example", 40, 300));
const res = await listRoute.GET(
await makeManagementSessionRequest("http://localhost/api/settings/free-proxies?search=alph")
);
const body = await res.json();
assert.equal(body.data.total, 1);
assert.equal(body.data.proxies[0].host, "alpha.example");
});
test("GET surfaces syncErrors when a source previously failed", async () => {
await reset();
await freeProxiesDb.recordFreeProxySyncErrors("proxifly", ["HTTP 429 from upstream"]);
const res = await listRoute.GET(
await makeManagementSessionRequest("http://localhost/api/settings/free-proxies")
);
const body = await res.json();
assert.deepEqual(body.data.syncErrors, { proxifly: ["HTTP 429 from upstream"] });
});
test("GET requires management auth", async () => {
await reset();
// A fresh DB has no configured password, so a loopback/unauthenticated
// request is treated as the pre-setup bootstrap path and allowed through
// (see `isAuthRequired` in src/shared/utils/apiAuth.ts). Configure a
// password + requireLogin so this test actually exercises the auth gate,
// matching the pattern used by tests/unit/api/settings-audit.test.ts.
process.env.INITIAL_PASSWORD = "free-proxies-list-route-test-password";
await settingsDb.updateSettings({ requireLogin: true });
const res = await listRoute.GET(new Request("http://localhost/api/settings/free-proxies"));
assert.equal(res.status, 401);
});

View File

@@ -0,0 +1,107 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { makeManagementSessionRequest } from "../../helpers/managementSession.ts";
// DATA_DIR must be set before the DB core module evaluates its singleton.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-api-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../../src/lib/db/core.ts");
const freeProxiesDb = await import("../../../src/lib/db/freeProxies.ts");
const settingsDb = await import("../../../src/lib/db/settings.ts");
const listRoute = await import("../../../src/app/api/settings/free-proxies/route.ts");
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
});
function seed(host: string, quality: number, latency: number) {
return freeProxiesDb.upsertFreeProxy({
source: "1proxy",
host,
port: 8080,
type: "http",
countryCode: "US",
qualityScore: quality,
latencyMs: latency,
anonymity: "elite",
lastValidated: "2026-01-01T00:00:00.000Z",
});
}
test("GET returns the full { proxies, total, hasMore, stats, syncErrors } shape", async () => {
await seed("alpha.example", 90, 100);
await seed("bravo.example", 40, 300);
const res = await listRoute.GET(
await makeManagementSessionRequest("http://localhost/api/settings/free-proxies")
);
assert.equal(res.status, 200);
const body = (await res.json()) as {
success: boolean;
data: {
proxies: unknown[];
total: number;
hasMore: boolean;
stats: { total: number; inPool: number; bySource: Array<{ source: string; count: number }> };
syncErrors: Record<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 () => {
// A fresh DB has no configured password, so a loopback/unauthenticated
// request is treated as the pre-setup bootstrap path and allowed through
// (see `isAuthRequired` in src/shared/utils/apiAuth.ts). Configure a
// password + requireLogin so this test actually exercises the auth gate,
// matching the pattern used by tests/unit/api/settings-audit.test.ts.
process.env.INITIAL_PASSWORD = "free-proxies-route-test-password";
await settingsDb.updateSettings({ requireLogin: true });
const res = await listRoute.GET(new Request("http://localhost/api/settings/free-proxies"));
assert.equal(res.status, 401);
});

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