mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
Release v3.8.41 — 52 commits since v3.8.40 (19 CHANGELOG bullets, 11 contributors). All gating CI green: Unit×8, Coverage×8, Vitest, Package Artifact, Quality Ratchet, CodeQL, Lint, Docs Sync (Strict), Node 24/26 compat, E2E×9, Integration, Electron smoke. Advisory checks overridden (main unprotected): PR Test Policy = test-masking heuristic on the cumulative 52-commit assert delta (legitimate dead-code-sweep removals + consolidations, reviewed per-PR); SonarCloud/SonarQube = new-code maintainability/coverage quality gate (CodeQL/Semgrep/Security/npm-audit/Dependabot all clean — not a security finding).
241 lines
7.3 KiB
TypeScript
241 lines
7.3 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);
|
|
}
|
|
}
|
|
}
|