Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/9291-proxy-logs-egress-ip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(db):** persist the account egress IP into `proxy_logs.egress_ip` (migration 134 + schema reconciler) so real traffic stays attributable to the actual node/IP even after restart — the egress IP was previously computed and logged but silently dropped from persistence ([#9291](https://github.com/diegosouzapw/OmniRoute/pull/9291)) — thanks @maxmad64bis
2 changes: 2 additions & 0 deletions src/lib/db/migrations/134_proxy_logs_egress_ip.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- egress_ip: no index by design (not a query dimension) — YAGNI
ALTER TABLE proxy_logs ADD COLUMN egress_ip TEXT;
16 changes: 16 additions & 0 deletions src/lib/db/schemaColumns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,22 @@ export function ensureCallLogsColumns(db: SqliteDatabase) {
}
}

export function ensureProxyLogsColumns(db: SqliteDatabase) {
try {
const columns = db.prepare("PRAGMA table_info(proxy_logs)").all() as Array<{
name?: string;
}>;
const columnNames = new Set(columns.map((column) => String(column.name ?? "")));
if (!columnNames.has("egress_ip")) {
db.exec("ALTER TABLE proxy_logs ADD COLUMN egress_ip TEXT");
console.log("[DB] Added proxy_logs.egress_ip column");
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn("[DB] Failed to verify proxy_logs schema:", message);
}
}

export function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
return rows.some((row) => row.name === columnName);
Expand Down
10 changes: 8 additions & 2 deletions src/lib/proxyLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/
import { v4 as uuidv4 } from "uuid";
import { getDbInstance, isCloud, isBuildPhase } from "./db/core";
import { ensureProxyLogsColumns } from "./db/schemaColumns";

const shouldPersistToDisk = !isCloud && !isBuildPhase;

Expand Down Expand Up @@ -64,6 +65,9 @@ function loadFromDb() {
if (!shouldPersistToDisk) return;
try {
const db = getDbInstance();
// Self-heal the proxy_logs schema before reading/writing (migration 134
// guarantees egress_ip on every migrated DB; this covers restored/odd states).
ensureProxyLogsColumns(db);
const rows = db
.prepare("SELECT * FROM proxy_logs ORDER BY timestamp DESC LIMIT ?")
.all(MAX_IN_MEMORY_ENTRIES) as any[];
Expand Down Expand Up @@ -145,10 +149,10 @@ export function logProxyEvent(entry: ProxyLogInput) {
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,
level, level_id, provider, target_url, public_ip, egress_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,
@level, @levelId, @provider, @targetUrl, @clientIp, @egressIp, @latencyMs, @error,
@connectionId, @comboId, @account, @tlsFingerprint)`
).run({
id: log.id,
Expand All @@ -162,6 +166,7 @@ export function logProxyEvent(entry: ProxyLogInput) {
provider: log.provider,
targetUrl: log.targetUrl,
clientIp: log.clientIp,
egressIp: log.egressIp,
latencyMs: log.latencyMs,
error: log.error,
connectionId: log.connectionId,
Expand Down Expand Up @@ -214,6 +219,7 @@ export function getProxyLogs(filters: ProxyLogFilters = {}) {
(l.provider || "").toLowerCase().includes(q) ||
(l.targetUrl || "").toLowerCase().includes(q) ||
(l.clientIp || "").toLowerCase().includes(q) ||
(l.egressIp || "").toLowerCase().includes(q) ||
(l.level || "").toLowerCase().includes(q) ||
(l.error || "").toLowerCase().includes(q) ||
(l.account || "").toLowerCase().includes(q)
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/db-schema-columns-split.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
// columns and is safe to re-run; hasTable/hasColumn/getTableColumns/quoteIdentifier introspect.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { tryOpenSync } from "../../src/lib/db/adapters/driverFactory.ts";
import {
ensureUsageHistoryColumns,
ensureProviderConnectionsColumns,
ensureProxyLogsColumns,
hasColumn,
hasTable,
quoteIdentifier,
Expand Down Expand Up @@ -85,3 +88,32 @@ test("ensureProviderConnectionsColumns repairs quota visibility with a visible d
db.close?.();
}
});

test("ensureProxyLogsColumns self-heals a bare proxy_logs (upgrade path)", () => {
const db = openMemoryDb();
try {
db.exec("CREATE TABLE proxy_logs (id TEXT PRIMARY KEY, timestamp TEXT NOT NULL)");
assert.equal(hasColumn(db, "proxy_logs", "egress_ip"), false);

ensureProxyLogsColumns(db);
assert.equal(hasColumn(db, "proxy_logs", "egress_ip"), true);
assert.doesNotThrow(() => ensureProxyLogsColumns(db));
} finally {
db.close?.();
}
});

test("migration 134 SQL applies egress_ip to a bare proxy_logs", () => {
const db = openMemoryDb();
try {
db.exec("CREATE TABLE proxy_logs (id TEXT PRIMARY KEY, timestamp TEXT NOT NULL)");
const sql = fs.readFileSync(
path.join(process.cwd(), "src/lib/db/migrations/134_proxy_logs_egress_ip.sql"),
"utf8"
);
db.exec(sql);
assert.equal(hasColumn(db, "proxy_logs", "egress_ip"), true);
} finally {
db.close?.();
}
});
86 changes: 86 additions & 0 deletions tests/unit/proxy-logs-egress-ip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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";

// Persistence to SQLite only runs when shouldPersistToDisk is true
// (local mode: !isCloud && !isBuildPhase). Setting DATA_DIR to a fresh temp
// dir keeps the test in local mode; the assertions below would otherwise fail
// with no explanatory guard.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-egress-ip-"));
process.env.DATA_DIR = TEST_DATA_DIR;

const core = await import("../../src/lib/db/core.ts");
const proxyLogger = await import("../../src/lib/proxyLogger.ts");

// Fresh DB + fresh in-memory buffer per test (mirrors
// proxy-logger-client-ip.test.ts). clearProxyLogs() runs BEFORE closeDbInstance()
// so it never reopens a closed DB.
function resetStorage() {
proxyLogger.clearProxyLogs();
core.closeDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}

test.beforeEach(() => {
resetStorage();
});

test.after(() => {
core.closeDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});

test("fresh install exposes egress_ip and the reconciler is idempotent", async () => {
const { ensureProxyLogsColumns, hasColumn } = await import("../../src/lib/db/schemaColumns.ts");
const db = core.getDbInstance();
assert.equal(hasColumn(db, "proxy_logs", "egress_ip"), true, "column exists after migration");
assert.doesNotThrow(() => ensureProxyLogsColumns(db));
assert.doesNotThrow(() => ensureProxyLogsColumns(db));
});

test("logProxyEvent persists egressIp into proxy_logs.egress_ip", () => {
proxyLogger.logProxyEvent({
status: "success",
provider: "codex",
targetUrl: "codex/gpt-5.5",
egressIp: "203.0.113.9",
});
const db = core.getDbInstance();
const row = db.prepare("SELECT egress_ip FROM proxy_logs ORDER BY rowid DESC LIMIT 1").get() as {
egress_ip: string | null;
};
assert.equal(row.egress_ip, "203.0.113.9");
});

test("egress_ip survives a DB close/reopen cycle (on-disk)", () => {
proxyLogger.logProxyEvent({
status: "success",
provider: "openai",
egressIp: "198.51.100.7",
});
core.closeDbInstance();
const db = core.getDbInstance();
const row = db.prepare("SELECT egress_ip FROM proxy_logs ORDER BY rowid DESC LIMIT 1").get() as {
egress_ip: string | null;
};
assert.equal(row.egress_ip, "198.51.100.7");
});

test("egress_ip is NULL when not provided (never synthesized)", () => {
proxyLogger.logProxyEvent({ status: "success", provider: "claude" });
const db = core.getDbInstance();
const row = db.prepare("SELECT egress_ip FROM proxy_logs ORDER BY rowid DESC LIMIT 1").get() as {
egress_ip: string | null;
};
assert.equal(row.egress_ip, null);
});

test("getProxyLogs search matches the egress IP", () => {
proxyLogger.logProxyEvent({ status: "success", provider: "codex", egressIp: "203.0.113.55" });
const [log] = proxyLogger.getProxyLogs({ search: "203.0.113.55" });
assert.ok(log, "expected a matching log");
assert.equal(log.egressIp, "203.0.113.55");
});
Loading