diff --git a/changelog.d/features/10677-egress-sharing-summary.md b/changelog.d/features/10677-egress-sharing-summary.md new file mode 100644 index 0000000000..9e1f723a0d --- /dev/null +++ b/changelog.d/features/10677-egress-sharing-summary.md @@ -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)) diff --git a/src/app/api/settings/proxies/egress/route.ts b/src/app/api/settings/proxies/egress/route.ts index b43fcd6e6f..dbad250361 100644 --- a/src/app/api/settings/proxies/egress/route.ts +++ b/src/app/api/settings/proxies/egress/route.ts @@ -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"); } diff --git a/src/lib/proxyEgress.ts b/src/lib/proxyEgress.ts index bcad4e2d7b..1996bec763 100644 --- a/src/lib/proxyEgress.ts +++ b/src/lib/proxyEgress.ts @@ -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(); + 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(); + 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. diff --git a/src/lib/proxyHealth/scheduler.ts b/src/lib/proxyHealth/scheduler.ts index 7e7c24f984..dc30453a34 100644 --- a/src/lib/proxyHealth/scheduler.ts +++ b/src/lib/proxyHealth/scheduler.ts @@ -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 { 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 { + // #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; diff --git a/src/lib/proxyLogger.ts b/src/lib/proxyLogger.ts index 79bb4e5af4..a9eb4b3805 100644 --- a/src/lib/proxyLogger.ts +++ b/src/lib/proxyLogger.ts @@ -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(), }) ); } diff --git a/tests/unit/proxy-egress-route-summary.test.ts b/tests/unit/proxy-egress-route-summary.test.ts new file mode 100644 index 0000000000..bd5bc45b9a --- /dev/null +++ b/tests/unit/proxy-egress-route-summary.test.ts @@ -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); +}); diff --git a/tests/unit/proxy-egress-summary.test.ts b/tests/unit/proxy-egress-summary.test.ts new file mode 100644 index 0000000000..6eddab01d1 --- /dev/null +++ b/tests/unit/proxy-egress-summary.test.ts @@ -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:", () => { + 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"); +}); diff --git a/tests/unit/proxy-health-egress-line.test.ts b/tests/unit/proxy-health-egress-line.test.ts new file mode 100644 index 0000000000..ce518ae665 --- /dev/null +++ b/tests/unit/proxy-health-egress-line.test.ts @@ -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; + 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"); +});