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:
Dizzle
2026-08-20 20:47:34 +02:00
committed by GitHub
parent 82ed31d27a
commit e6801bace1
8 changed files with 485 additions and 7 deletions

View 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))

View File

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

View File

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

View File

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

View File

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

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

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

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