mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
feat(proxy): surface anonymous egress-IP sharing in the health sweep and the egress API (#10876)
Obrigado — expõe onde o operador realmente olha (sweep periódico de saúde de proxy + resposta da API de egress) o sinal de compartilhamento anônimo de IP de egress entre contas de um mesmo rotation group, que já existia (analyzeEgressSharing) mas só era acessível via curl autenticado. Fecha #10677. Respeita a decisão de redação do #10348/#10539: apenas contagens por padrão, nenhum IP/identidade de conta a menos que PROXY_LOG_INCLUDE_IPS=true. Validação (worktree combinado a partir de origin/release/v3.8.50, 0 conflitos): - typecheck:core limpo, complexity/cognitive-complexity dentro do baseline - tests/unit/proxy-egress-route-summary.test.ts + proxy-egress-summary.test.ts + proxy-health-egress-line.test.ts — 23/23 passando (agregado, formatter, linha do sweep com output real capturado, rota completa com auth de management)
This commit is contained in:
1
changelog.d/features/10677-egress-sharing-summary.md
Normal file
1
changelog.d/features/10677-egress-sharing-summary.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxy):** the proxy-health sweep and `GET /api/settings/proxies/egress` now report an anonymous summary of egress-IP sharing — how many rotation groups share an egress IP and the largest number of accounts behind one IP — computed from persisted `proxy_logs` over a 24h window. No IPs and no account identities by default; `PROXY_LOG_INCLUDE_IPS=true` restores raw details. ([#10677](https://github.com/diegosouzapw/OmniRoute/issues/10677))
|
||||
@@ -1,7 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import { diagnoseAllEgressIps, validateProxyPool } from "@/lib/proxyEgress";
|
||||
import {
|
||||
diagnoseAllEgressIps,
|
||||
getRecentEgressSharingSummary,
|
||||
validateProxyPool,
|
||||
} from "@/lib/proxyEgress";
|
||||
|
||||
/**
|
||||
* GET /api/settings/proxies/egress — diagnose the egress IP of every OAuth
|
||||
@@ -17,8 +21,11 @@ export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
try {
|
||||
const diagnostic = await diagnoseAllEgressIps();
|
||||
return NextResponse.json(diagnostic);
|
||||
const [diagnostic, { summary }] = await Promise.all([
|
||||
diagnoseAllEgressIps(),
|
||||
getRecentEgressSharingSummary(),
|
||||
]);
|
||||
return NextResponse.json({ ...diagnostic, summary });
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to diagnose egress IPs");
|
||||
}
|
||||
|
||||
@@ -196,6 +196,110 @@ export function analyzeEgressSharing(connections: ConnectionEgress[]): {
|
||||
return { byEgressIp, sharedWithinRotationGroup };
|
||||
}
|
||||
|
||||
export const EGRESS_SHARING_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface EgressLogRow {
|
||||
provider: string | null;
|
||||
account: string | null;
|
||||
connectionId: string | null;
|
||||
egressIp: string | null;
|
||||
}
|
||||
|
||||
export interface EgressSharingSummary {
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
distinctEgressIps: number;
|
||||
sharingByRotationGroup: Array<{
|
||||
rotationGroup: string;
|
||||
sharedIps: number;
|
||||
maxAccountsSharingOneIp: number;
|
||||
}>;
|
||||
maxAccountsSharingOneIp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE: anonymous egress-IP sharing summary over proxy_logs-shaped rows.
|
||||
* Dedupes per connection (proxy_logs holds one row per request, not per
|
||||
* connection) — "max accounts behind one IP" therefore counts connections,
|
||||
* not distinct accounts, when one account spans several connections. Reuses
|
||||
* analyzeEgressSharing's rotation-group semantics and returns counts only —
|
||||
* no IP literals, no account identities (#10348).
|
||||
*/
|
||||
export function summarizeEgressSharing(
|
||||
rows: EgressLogRow[],
|
||||
window: { start: string; end: string }
|
||||
): { summary: EgressSharingSummary; warnings: EgressSharingWarning[] } {
|
||||
const byAccount = new Map<string, EgressLogRow>();
|
||||
for (const r of rows) {
|
||||
if (!r.egressIp) continue;
|
||||
const key = r.connectionId ?? r.account;
|
||||
if (!key) continue;
|
||||
if (!byAccount.has(key)) byAccount.set(key, r);
|
||||
}
|
||||
|
||||
const connections = [...byAccount.values()].map((r) => ({
|
||||
connectionId: r.connectionId ?? r.account ?? "unknown",
|
||||
provider: r.provider ?? "",
|
||||
account: r.account ?? r.connectionId,
|
||||
proxyLevel: "log",
|
||||
proxyHost: null,
|
||||
egressIp: r.egressIp,
|
||||
}));
|
||||
|
||||
const { byEgressIp, sharedWithinRotationGroup } = analyzeEgressSharing(connections);
|
||||
|
||||
const byGroup = new Map<string, { sharedIps: number; maxAccountsSharingOneIp: number }>();
|
||||
let maxAccountsSharingOneIp = 0;
|
||||
for (const w of sharedWithinRotationGroup) {
|
||||
const g = byGroup.get(w.rotationGroup) ?? { sharedIps: 0, maxAccountsSharingOneIp: 0 };
|
||||
g.sharedIps++;
|
||||
g.maxAccountsSharingOneIp = Math.max(g.maxAccountsSharingOneIp, w.connections.length);
|
||||
byGroup.set(w.rotationGroup, g);
|
||||
maxAccountsSharingOneIp = Math.max(maxAccountsSharingOneIp, w.connections.length);
|
||||
}
|
||||
|
||||
return {
|
||||
summary: {
|
||||
windowStart: window.start,
|
||||
windowEnd: window.end,
|
||||
distinctEgressIps: Object.keys(byEgressIp).length,
|
||||
sharingByRotationGroup: [...byGroup.entries()].map(([rotationGroup, v]) => ({
|
||||
rotationGroup,
|
||||
sharedIps: v.sharedIps,
|
||||
maxAccountsSharingOneIp: v.maxAccountsSharingOneIp,
|
||||
})),
|
||||
maxAccountsSharingOneIp,
|
||||
},
|
||||
// Raw warnings carry IPs and labels — only ever rendered behind the
|
||||
// PROXY_LOG_INCLUDE_IPS opt-in (#10348), never in the summary itself.
|
||||
warnings: sharedWithinRotationGroup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DB-backed: anonymous egress-sharing summary over the last
|
||||
* EGRESS_SHARING_WINDOW_MS of persisted proxy_logs (egress_ip is always
|
||||
* persisted even when the process log line is redacted). No live probes.
|
||||
* Single place where proxy_logs rows are mapped to EgressLogRow — the sweep
|
||||
* and the route both consume this helper. Reads all rows in the window (the
|
||||
* existing SELECT * has no LIMIT); bounded by the 24h window.
|
||||
*/
|
||||
export async function getRecentEgressSharingSummary(): Promise<{
|
||||
summary: EgressSharingSummary;
|
||||
warnings: EgressSharingWarning[];
|
||||
}> {
|
||||
const { exportProxyLogsSince } = await import("./db/proxyLogs");
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - EGRESS_SHARING_WINDOW_MS);
|
||||
const rows: EgressLogRow[] = exportProxyLogsSince(start.toISOString()).map((r) => ({
|
||||
provider: (r.provider as string | null) ?? null,
|
||||
account: (r.account as string | null) ?? null,
|
||||
connectionId: (r.connection_id as string | null) ?? null,
|
||||
egressIp: (r.egress_ip as string | null) ?? null,
|
||||
}));
|
||||
return summarizeEgressSharing(rows, { start: start.toISOString(), end: end.toISOString() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnose egress IPs for every OAuth connection: resolve each connection's
|
||||
* proxy, probe the real egress IP, and flag same-rotation-group IP sharing.
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
*/
|
||||
|
||||
import { deleteProxyById, listProxies, updateProxy } from "@/lib/localDb";
|
||||
import { isProxyLogIncludeIps } from "@/lib/proxyLogger";
|
||||
import {
|
||||
getRecentEgressSharingSummary,
|
||||
type EgressSharingSummary,
|
||||
type EgressSharingWarning,
|
||||
} from "@/lib/proxyEgress";
|
||||
import {
|
||||
createProxyDispatcher,
|
||||
clearDispatcherCache,
|
||||
@@ -70,6 +76,26 @@ function getFailureMap(): Map<string, number> {
|
||||
return globalThis.__proxyHealthConsecutiveFailures;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE: one-line anonymous egress-sharing summary for the sweep log (#10677).
|
||||
* Counts only by default; raw shared IPs only when PROXY_LOG_INCLUDE_IPS=true
|
||||
* (the redaction decision from #10348 — never leak IPs or account labels).
|
||||
*/
|
||||
export function formatEgressSharingSummaryLine(
|
||||
summary: EgressSharingSummary,
|
||||
warnings: EgressSharingWarning[],
|
||||
includeDetails: boolean
|
||||
): string {
|
||||
const base =
|
||||
`${LOG_PREFIX} egress: ${summary.sharingByRotationGroup.length} rotation group(s) share an ` +
|
||||
`egress IP (max ${summary.maxAccountsSharingOneIp} accounts)`;
|
||||
if (!includeDetails) return base;
|
||||
const detail = warnings
|
||||
.map((w) => `${w.rotationGroup}: ${w.egressIp} (${w.connections.length} accounts)`)
|
||||
.join(", ");
|
||||
return detail ? `${base} — ${detail}` : base;
|
||||
}
|
||||
|
||||
function isEnabled(): boolean {
|
||||
return process.env.PROXY_HEALTH_ENABLED !== "false";
|
||||
}
|
||||
@@ -162,6 +188,21 @@ async function testOneProxy(proxy: {
|
||||
}
|
||||
|
||||
async function sweep(): Promise<void> {
|
||||
// #10677: anonymous egress-sharing signal from persisted proxy_logs (no live
|
||||
// probes). Logged only when sharing exists — the sweep line is a warning
|
||||
// signal, not a heartbeat. Runs before the empty-registry early return so
|
||||
// sharing from direct connections is still reported when no proxies are
|
||||
// configured. Never let a DB hiccup suppress the completion line or fail the
|
||||
// sweep itself.
|
||||
try {
|
||||
const { summary, warnings } = await getRecentEgressSharingSummary();
|
||||
if (summary.sharingByRotationGroup.length > 0) {
|
||||
console.log(formatEgressSharingSummaryLine(summary, warnings, isProxyLogIncludeIps()));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} Egress summary skipped:`, error);
|
||||
}
|
||||
|
||||
const { items: proxies } = await listProxies({ includeSecrets: true });
|
||||
if (proxies.length === 0) return;
|
||||
|
||||
|
||||
@@ -110,9 +110,14 @@ loadFromDb();
|
||||
// neither IPs nor the account prefix. Deliberately NOT coupled to debugMode
|
||||
// (src/lib/db/settings.ts defaults debugMode to true) — this verbosity is opt-in only.
|
||||
// Storage (in-memory ring buffer + SQLite) is untouched and always keeps full IPs.
|
||||
const PROXY_LOG_INCLUDE_IPS =
|
||||
process.env.PROXY_LOG_INCLUDE_IPS === "true" ||
|
||||
process.env.PROXY_LOG_INCLUDE_IPS === "1";
|
||||
|
||||
/** Read at call time so tests can toggle it between imports. */
|
||||
export function isProxyLogIncludeIps(): boolean {
|
||||
return (
|
||||
process.env.PROXY_LOG_INCLUDE_IPS === "true" ||
|
||||
process.env.PROXY_LOG_INCLUDE_IPS === "1"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure formatter for the [ProxyEgress] process-log line (#10348). At the default level it
|
||||
@@ -178,7 +183,7 @@ export function logProxyEvent(entry: ProxyLogInput) {
|
||||
level: log.level,
|
||||
proxyHost: log.proxy?.host,
|
||||
status: log.status,
|
||||
includeDetails: PROXY_LOG_INCLUDE_IPS,
|
||||
includeDetails: isProxyLogIncludeIps(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
83
tests/unit/proxy-egress-route-summary.test.ts
Normal file
83
tests/unit/proxy-egress-route-summary.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* GET /api/settings/proxies/egress returns the existing
|
||||
* diagnostic payload PLUS an additive anonymous `summary` computed from
|
||||
* persisted proxy_logs. Auth pattern from api-auth.test.ts; probe seam from
|
||||
* proxy-egress-visibility.test.ts.
|
||||
*/
|
||||
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";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-egress-route-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES = "true";
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const proxyLogger = await import("../../src/lib/proxyLogger.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const route = await import("../../src/app/api/settings/proxies/egress/route.ts");
|
||||
|
||||
function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
proxyLogger.clearProxyLogs();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function setupAuth() {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password"; // pragma: allowlist secret (fixture du dépôt, même valeur que api-auth.test.ts)
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
const key = await apiKeysDb.createApiKey("admin-key", "machine-test", ["manage"]);
|
||||
return key.key;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("GET /api/settings/proxies/egress adds an anonymous summary to the existing payload", async () => {
|
||||
const bearer = await setupAuth();
|
||||
|
||||
// Seed two codex accounts on one egress IP (persisted proxy_logs).
|
||||
proxyLogger.logProxyEvent({ status: "success", provider: "codex", targetUrl: "codex/gpt-5.5", egressIp: "100.115.194.84", account: "acc-a", connectionId: "conn-a" });
|
||||
proxyLogger.logProxyEvent({ status: "success", provider: "codex", targetUrl: "codex/gpt-5.5", egressIp: "100.115.194.84", account: "acc-b", connectionId: "conn-b" });
|
||||
|
||||
const response = await route.GET(new Request("https://example.com/api/settings/proxies/egress", {
|
||||
headers: { authorization: `Bearer ${bearer}` },
|
||||
}));
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
// Existing payload shape untouched.
|
||||
assert.ok(Array.isArray(body.connections));
|
||||
assert.ok(body.byEgressIp && typeof body.byEgressIp === "object");
|
||||
assert.ok(Array.isArray(body.sharedWithinRotationGroup));
|
||||
|
||||
// Additive anonymous summary.
|
||||
assert.equal(body.summary.distinctEgressIps, 1);
|
||||
assert.equal(body.summary.maxAccountsSharingOneIp, 2);
|
||||
assert.equal(body.summary.sharingByRotationGroup[0].rotationGroup, "openai-auth0");
|
||||
const json = JSON.stringify(body.summary);
|
||||
assert.ok(!json.includes("100.115.194.84"), "no IP literal in the summary");
|
||||
assert.ok(!json.includes("acc-a"), "no account identity in the summary");
|
||||
});
|
||||
|
||||
test("GET returns 401 without a management token (auth untouched)", async () => {
|
||||
// Self-contained auth setup (the reference api-auth.test.ts calls setupAuth
|
||||
// inside each auth test) — no reliance on a previous test's env leakage.
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password"; // pragma: allowlist secret (fixture du dépôt, même valeur que api-auth.test.ts)
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
|
||||
const response = await route.GET(new Request("https://example.com/api/settings/proxies/egress"));
|
||||
assert.equal(response.status, 401);
|
||||
});
|
||||
118
tests/unit/proxy-egress-summary.test.ts
Normal file
118
tests/unit/proxy-egress-summary.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Anonymous egress-IP sharing summary (#10677). The pure aggregate
|
||||
* must never leak IP literals or account identities — the redaction decision
|
||||
* from #10348/#10539. Dedupes proxy_logs rows per account, reuses
|
||||
* analyzeEgressSharing's rotation-group semantics.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { summarizeEgressSharing } = (await import("../../src/lib/proxyEgress.ts")) as unknown as {
|
||||
summarizeEgressSharing: (
|
||||
rows: Array<{
|
||||
provider: string | null;
|
||||
account: string | null;
|
||||
connectionId: string | null;
|
||||
egressIp: string | null;
|
||||
}>,
|
||||
window: { start: string; end: string }
|
||||
) => {
|
||||
summary: {
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
distinctEgressIps: number;
|
||||
sharingByRotationGroup: Array<{
|
||||
rotationGroup: string;
|
||||
sharedIps: number;
|
||||
maxAccountsSharingOneIp: number;
|
||||
}>;
|
||||
maxAccountsSharingOneIp: number;
|
||||
};
|
||||
warnings: Array<{ egressIp: string; rotationGroup: string; connections: string[] }>;
|
||||
};
|
||||
};
|
||||
|
||||
const WINDOW = { start: "2026-08-20T00:00:00.000Z", end: "2026-08-21T00:00:00.000Z" };
|
||||
const row = (
|
||||
over: Partial<{ provider: string; account: string; connectionId: string; egressIp: string }>
|
||||
) => ({
|
||||
provider: "codex",
|
||||
account: "acc-a",
|
||||
connectionId: "conn-a",
|
||||
egressIp: "100.115.194.84",
|
||||
...over,
|
||||
});
|
||||
|
||||
test("summarizeEgressSharing: two accounts of one rotation group on the same IP", () => {
|
||||
const { summary: s } = summarizeEgressSharing(
|
||||
[
|
||||
row({ account: "acc-a", connectionId: "conn-a" }),
|
||||
row({ account: "acc-b", connectionId: "conn-b" }),
|
||||
],
|
||||
WINDOW
|
||||
);
|
||||
assert.equal(s.windowStart, WINDOW.start);
|
||||
assert.equal(s.windowEnd, WINDOW.end);
|
||||
assert.equal(s.distinctEgressIps, 1);
|
||||
assert.equal(s.maxAccountsSharingOneIp, 2);
|
||||
assert.equal(s.sharingByRotationGroup.length, 1);
|
||||
assert.equal(s.sharingByRotationGroup[0].rotationGroup, "openai-auth0"); // codex+openai family
|
||||
assert.equal(s.sharingByRotationGroup[0].sharedIps, 1);
|
||||
assert.equal(s.sharingByRotationGroup[0].maxAccountsSharingOneIp, 2);
|
||||
});
|
||||
|
||||
test("summarizeEgressSharing: repeated rows of the same account on one IP count once", () => {
|
||||
const { summary: s } = summarizeEgressSharing(
|
||||
[
|
||||
row({ account: "acc-a", connectionId: "conn-a" }),
|
||||
row({ account: "acc-a", connectionId: "conn-a" }),
|
||||
row({ account: "acc-b", connectionId: "conn-b" }),
|
||||
row({ account: "acc-b", connectionId: "conn-b" }),
|
||||
],
|
||||
WINDOW
|
||||
);
|
||||
assert.equal(s.maxAccountsSharingOneIp, 2, "dedupe per account, not per request");
|
||||
});
|
||||
|
||||
test("summarizeEgressSharing: rows without egressIp are ignored", () => {
|
||||
const { summary: s } = summarizeEgressSharing([row({ egressIp: null })], WINDOW);
|
||||
assert.equal(s.distinctEgressIps, 0);
|
||||
assert.equal(s.maxAccountsSharingOneIp, 0);
|
||||
assert.deepEqual(s.sharingByRotationGroup, []);
|
||||
});
|
||||
|
||||
test("summarizeEgressSharing: distinct IPs per account = no sharing", () => {
|
||||
const { summary: s } = summarizeEgressSharing(
|
||||
[
|
||||
row({ egressIp: "203.0.113.1" }),
|
||||
row({ account: "acc-b", connectionId: "conn-b", egressIp: "203.0.113.2" }),
|
||||
],
|
||||
WINDOW
|
||||
);
|
||||
assert.equal(s.distinctEgressIps, 2);
|
||||
assert.equal(s.sharingByRotationGroup.length, 0);
|
||||
});
|
||||
|
||||
test("summarizeEgressSharing: summary carries counts only — no IP, no account", () => {
|
||||
const { summary: s } = summarizeEgressSharing(
|
||||
[row({}), row({ account: "acc-b", connectionId: "conn-b" })],
|
||||
WINDOW
|
||||
);
|
||||
const json = JSON.stringify(s);
|
||||
assert.ok(!json.includes("100.115.194.84"), "no IP literal in the summary");
|
||||
assert.ok(
|
||||
!json.includes("acc-a") && !json.includes("acc-b"),
|
||||
"no account identity in the summary"
|
||||
);
|
||||
});
|
||||
|
||||
test("summarizeEgressSharing: provider without rotation group falls back to provider:<name>", () => {
|
||||
const { summary: s } = summarizeEgressSharing(
|
||||
[
|
||||
row({ provider: "weird-provider" }),
|
||||
row({ provider: "weird-provider", account: "acc-b", connectionId: "conn-b" }),
|
||||
],
|
||||
WINDOW
|
||||
);
|
||||
assert.equal(s.sharingByRotationGroup[0].rotationGroup, "provider:weird-provider");
|
||||
});
|
||||
119
tests/unit/proxy-health-egress-line.test.ts
Normal file
119
tests/unit/proxy-health-egress-line.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* The proxy-health sweep logs an anonymous egress-sharing
|
||||
* summary line, computed from persisted proxy_logs (#10677) — no IP literals, no
|
||||
* account identities (PROXY_LOG_INCLUDE_IPS is the only raw-detail opt-in,
|
||||
* #10348). Console output is captured to prove the real sweep() emits it.
|
||||
*/
|
||||
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";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-egress-line-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES = "true";
|
||||
delete process.env.PROXY_LOG_INCLUDE_IPS;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const proxyLogger = await import("../../src/lib/proxyLogger.ts");
|
||||
const proxiesDb = await import("../../src/lib/db/proxies.ts");
|
||||
const { forceProxyHealthSweep, formatEgressSharingSummaryLine } = await import(
|
||||
"../../src/lib/proxyHealth/scheduler.ts"
|
||||
) as unknown as {
|
||||
forceProxyHealthSweep: () => Promise<void>;
|
||||
formatEgressSharingSummaryLine: (
|
||||
summary: EgressSharingSummary,
|
||||
warnings: Array<{ egressIp: string; rotationGroup: string; connections: string[] }>,
|
||||
includeDetails: boolean
|
||||
) => string;
|
||||
};
|
||||
import type { EgressSharingSummary } from "../../src/lib/proxyEgress.ts";
|
||||
|
||||
function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
proxyLogger.clearProxyLogs();
|
||||
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 });
|
||||
delete process.env.PROXY_LOG_INCLUDE_IPS;
|
||||
});
|
||||
|
||||
test("formatEgressSharingSummaryLine is anonymous by default and raw when opted in", () => {
|
||||
const summary: EgressSharingSummary = {
|
||||
windowStart: "2026-08-20T00:00:00.000Z",
|
||||
windowEnd: "2026-08-21T00:00:00.000Z",
|
||||
distinctEgressIps: 1,
|
||||
sharingByRotationGroup: [{ rotationGroup: "openai-auth0", sharedIps: 1, maxAccountsSharingOneIp: 2 }],
|
||||
maxAccountsSharingOneIp: 2,
|
||||
};
|
||||
const warnings = [{ egressIp: "100.115.194.84", rotationGroup: "openai-auth0", connections: ["a", "b"] }];
|
||||
|
||||
const anonymous = formatEgressSharingSummaryLine(summary, warnings, false);
|
||||
assert.equal(anonymous, "[ProxyHealth] egress: 1 rotation group(s) share an egress IP (max 2 accounts)");
|
||||
assert.ok(!anonymous.includes("100.115.194.84"), "no IP without opt-in");
|
||||
|
||||
const raw = formatEgressSharingSummaryLine(summary, warnings, true);
|
||||
assert.ok(raw.includes("100.115.194.84"), "raw IP only with PROXY_LOG_INCLUDE_IPS");
|
||||
});
|
||||
|
||||
test("forceProxyHealthSweep logs the anonymous egress line when accounts share an IP", async () => {
|
||||
resetStorage();
|
||||
|
||||
await proxiesDb.createProxy({
|
||||
name: "Dead Local Proxy",
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 1, // nothing listens — immediate ECONNREFUSED, no outbound traffic
|
||||
});
|
||||
|
||||
// Two codex accounts on one egress IP, persisted (the sweep reads the DB).
|
||||
proxyLogger.logProxyEvent({ status: "success", provider: "codex", targetUrl: "codex/gpt-5.5", egressIp: "100.115.194.84", account: "acc-a", connectionId: "conn-a" });
|
||||
proxyLogger.logProxyEvent({ status: "success", provider: "codex", targetUrl: "codex/gpt-5.5", egressIp: "100.115.194.84", account: "acc-b", connectionId: "conn-b" });
|
||||
|
||||
const logs: string[] = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => { logs.push(args.join(" ")); };
|
||||
try {
|
||||
await forceProxyHealthSweep();
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
const line = logs.find((l) => l.includes("[ProxyHealth] egress:"));
|
||||
assert.ok(line, "sweep must log the egress summary line");
|
||||
assert.ok(line!.includes("rotation group(s) share an egress IP (max 2 accounts)"), line!);
|
||||
assert.ok(!line!.includes("100.115.194.84"), "no IP literal in the default sweep line");
|
||||
assert.ok(!line!.includes("acc-a"), "no account identity in the default sweep line");
|
||||
});
|
||||
|
||||
test("forceProxyHealthSweep logs raw details only with PROXY_LOG_INCLUDE_IPS=true", async () => {
|
||||
resetStorage();
|
||||
process.env.PROXY_LOG_INCLUDE_IPS = "true";
|
||||
|
||||
await proxiesDb.createProxy({
|
||||
name: "Dead Local Proxy",
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 1,
|
||||
});
|
||||
proxyLogger.logProxyEvent({ status: "success", provider: "codex", targetUrl: "codex/gpt-5.5", egressIp: "100.115.194.84", account: "acc-a", connectionId: "conn-a" });
|
||||
proxyLogger.logProxyEvent({ status: "success", provider: "codex", targetUrl: "codex/gpt-5.5", egressIp: "100.115.194.84", account: "acc-b", connectionId: "conn-b" });
|
||||
|
||||
const logs: string[] = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => { logs.push(args.join(" ")); };
|
||||
try {
|
||||
await forceProxyHealthSweep();
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
|
||||
const line = logs.find((l) => l.includes("[ProxyHealth] egress:"));
|
||||
assert.ok(line, "sweep must log the egress summary line");
|
||||
assert.ok(line!.includes("100.115.194.84"), "raw IP restored by the opt-in");
|
||||
});
|
||||
Reference in New Issue
Block a user