Files
OmniRoute/src/lib/proxyLogger.ts
diegosouzapw f2d7df1f94 feat(proxy): egress IP visibility + pool validation (codex anomaly)
The proxy logs captured the INBOUND client IP (x-forwarded-for) but never the
OUTBOUND/egress IP — so there was no way to confirm by which IP each account
leaves. For rotating providers that is the whole game: probing the servers
showed every codex account had a distinct proxy assigned, but ALL proxies were
dead (egress timeout) while still marked status=active, so accounts fell back
to the shared host IPv6 /64 (same /64 across .16/.17/.18) → OpenAI anomaly
revocation ("authentication token has been invalidated").

- src/lib/proxyEgress.ts: resolveEgressIp() (echo-IP via the resolved proxy,
  cached), analyzeEgressSharing() (flags >=2 same-rotation-group accounts on
  one egress IP), diagnoseAllEgressIps(), and validateProxyPool() (probes each
  proxy and persists status=active/error so the dead-proxy filter takes them out
  of rotation automatically).
- proxyLogger: new egressIp field + a structured [ProxyEgress] line
  (in=clientIp out=egressIp) so the proxy logs show entry AND exit IP.
- chatHelpers: populate egressIp from a non-blocking cache, warm in background.
- GET/POST /api/settings/proxies/egress: diagnose + validate from the dashboard.

TDD: 5 tests (egress resolve/cache, sharing analysis, diagnose wiring, pool
validation). 37/37 proxy+oauth tests green, typecheck:core=0, lint clean.
2026-06-12 00:25:13 -03:00

252 lines
7.8 KiB
TypeScript

/**
* Proxy Logger — Hybrid in-memory + SQLite persistence
*
* Keeps a fast in-memory ring buffer for real-time dashboard AND
* persists to SQLite so logs survive server restarts.
*
* Pattern follows callLogs.js (T-15 decomposition).
*/
import { v4 as uuidv4 } from "uuid";
import { getDbInstance, isCloud, isBuildPhase } from "./db/core";
const shouldPersistToDisk = !isCloud && !isBuildPhase;
const MAX_IN_MEMORY_ENTRIES = 200;
interface ProxyInfo {
type: string;
host: string;
port: number | string;
}
interface ProxyLogEntry {
id: string;
timestamp: string;
status: string;
proxy: ProxyInfo | null;
level: string;
levelId: string | null;
provider: string | null;
targetUrl: string | null;
clientIp: string | null;
/** Outbound/egress IP the upstream actually saw (null until probed). The
* historical clientIp is the INBOUND IP (x-forwarded-for); egressIp answers
* "by which IP is this account leaving" — critical for rotating providers. */
egressIp: string | null;
latencyMs: number;
error: string | null;
connectionId: string | null;
comboId: string | null;
account: string | null;
tlsFingerprint: boolean;
}
type ProxyLogInput = Partial<ProxyLogEntry> & {
publicIp?: string | null;
};
interface ProxyLogFilters {
status?: string;
type?: string;
provider?: string;
level?: string;
search?: string;
limit?: number;
}
const proxyLogs: ProxyLogEntry[] = [];
// `public_ip` is the historical SQLite column name; API/UI expose the value as clientIp.
// ──────────────── Startup: hydrate from DB ────────────────
function loadFromDb() {
if (!shouldPersistToDisk) return;
try {
const db = getDbInstance();
const rows = db
.prepare("SELECT * FROM proxy_logs ORDER BY timestamp DESC LIMIT ?")
.all(MAX_IN_MEMORY_ENTRIES) as any[];
for (const row of rows) {
proxyLogs.push({
id: row.id,
timestamp: row.timestamp,
status: row.status || "success",
proxy: row.proxy_host
? { type: row.proxy_type, host: row.proxy_host, port: row.proxy_port }
: null,
level: row.level || "direct",
levelId: row.level_id || null,
provider: row.provider || null,
targetUrl: row.target_url || null,
clientIp: row.public_ip || null,
egressIp: row.egress_ip || null,
latencyMs: row.latency_ms || 0,
error: row.error || null,
connectionId: row.connection_id || null,
comboId: row.combo_id || null,
account: row.account || null,
tlsFingerprint: row.tls_fingerprint === 1,
});
}
if (proxyLogs.length > 0) {
console.log(`[proxyLogger] Loaded ${proxyLogs.length} proxy logs from SQLite`);
}
} catch (err: any) {
console.warn("[proxyLogger] Failed to load from DB:", err.message);
}
}
loadFromDb();
// ──────────────── Log a proxy event ────────────────
export function logProxyEvent(entry: ProxyLogInput) {
const log: ProxyLogEntry = {
id: uuidv4(),
timestamp: new Date().toISOString(),
status: entry.status || "success",
proxy: entry.proxy || null,
level: entry.level || "direct",
levelId: entry.levelId || null,
provider: entry.provider || null,
targetUrl: entry.targetUrl || null,
clientIp: entry.clientIp ?? entry.publicIp ?? null,
egressIp: entry.egressIp ?? null,
latencyMs: entry.latencyMs || 0,
error: entry.error || null,
connectionId: entry.connectionId || null,
comboId: entry.comboId || null,
account: entry.account || null,
tlsFingerprint: entry.tlsFingerprint || false,
};
// Structured egress line so the operator can confirm, in the proxy logs, which
// IP each account is entering (clientIp) and leaving (egressIp) by.
if (log.proxy || log.egressIp) {
console.log(
`[ProxyEgress] ${log.provider || "-"}/${log.account || "-"} ` +
`in=${log.clientIp || "?"} out=${log.egressIp || "?"} ` +
`proxy=${log.level}${log.proxy ? `:${log.proxy.host}` : ""} status=${log.status}`
);
}
// 1. In-memory ring buffer (newest first)
proxyLogs.unshift(log);
if (proxyLogs.length > MAX_IN_MEMORY_ENTRIES) {
proxyLogs.length = MAX_IN_MEMORY_ENTRIES;
}
// 2. Persist to SQLite
if (shouldPersistToDisk) {
try {
const db = getDbInstance();
db.prepare(
`INSERT INTO proxy_logs (id, timestamp, status, proxy_type, proxy_host, proxy_port,
level, level_id, provider, target_url, public_ip, latency_ms, error,
connection_id, combo_id, account, tls_fingerprint)
VALUES (@id, @timestamp, @status, @proxyType, @proxyHost, @proxyPort,
@level, @levelId, @provider, @targetUrl, @clientIp, @latencyMs, @error,
@connectionId, @comboId, @account, @tlsFingerprint)`
).run({
id: log.id,
timestamp: log.timestamp,
status: log.status,
proxyType: log.proxy?.type || null,
proxyHost: log.proxy?.host || null,
proxyPort: log.proxy?.port ? Number(log.proxy.port) : null,
level: log.level,
levelId: log.levelId,
provider: log.provider,
targetUrl: log.targetUrl,
clientIp: log.clientIp,
latencyMs: log.latencyMs,
error: log.error,
connectionId: log.connectionId,
comboId: log.comboId,
account: log.account,
tlsFingerprint: log.tlsFingerprint ? 1 : 0,
});
} catch (err: any) {
console.warn("[proxyLogger] Failed to persist:", err.message);
}
}
return log;
}
// ──────────────── Query ────────────────
/**
* Get proxy logs with optional filters.
* Reads from in-memory for speed (already hydrated from DB on startup).
*/
export function getProxyLogs(filters: ProxyLogFilters = {}) {
let logs = [...proxyLogs];
if (filters.status) {
if (filters.status === "ok") {
logs = logs.filter((l) => l.status === "success");
} else {
logs = logs.filter((l) => l.status === filters.status);
}
}
if (filters.type) {
logs = logs.filter((l) => l.proxy?.type === filters.type);
}
if (filters.provider) {
logs = logs.filter((l) => l.provider === filters.provider);
}
if (filters.level) {
logs = logs.filter((l) => l.level === filters.level);
}
if (filters.search) {
const q = filters.search.toLowerCase();
logs = logs.filter(
(l) =>
(l.proxy?.host || "").toLowerCase().includes(q) ||
(l.provider || "").toLowerCase().includes(q) ||
(l.targetUrl || "").toLowerCase().includes(q) ||
(l.clientIp || "").toLowerCase().includes(q) ||
(l.level || "").toLowerCase().includes(q) ||
(l.error || "").toLowerCase().includes(q) ||
(l.account || "").toLowerCase().includes(q)
);
}
const limit = filters.limit || 300;
return logs.slice(0, limit);
}
// ──────────────── Clear ────────────────
export function clearProxyLogs() {
proxyLogs.length = 0;
if (shouldPersistToDisk) {
try {
const db = getDbInstance();
db.prepare("DELETE FROM proxy_logs").run();
} catch (err: any) {
console.warn("[proxyLogger] Failed to clear DB:", err.message);
}
}
}
// ──────────────── Stats ────────────────
export function getProxyLogStats() {
const total = proxyLogs.length;
const success = proxyLogs.filter((l) => l.status === "success").length;
const error = proxyLogs.filter((l) => l.status === "error").length;
const timeout = proxyLogs.filter((l) => l.status === "timeout").length;
const direct = proxyLogs.filter((l) => l.level === "direct").length;
return { total, success, error, timeout, direct };
}