в1
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# NOVAVPS - ссылка на страницу с подключениями
|
||||
NOVAVPS_URL=https://novavps.app/s/your_link_here
|
||||
|
||||
# Панель Xray 1 (основная)
|
||||
XRAY_URL=https://your-xray-panel.com/panel/xray
|
||||
XRAY_USERNAME=your_username
|
||||
XRAY_PASSWORD=your_password
|
||||
|
||||
# Панель Xray 2 (резервная, опционально)
|
||||
XRAY_URL2=https://your-second-xray-panel.com/panel/xray
|
||||
XRAY_USERNAME2=your_username2
|
||||
XRAY_PASSWORD2=your_password2
|
||||
|
||||
# Порт веб-интерфейса
|
||||
PORT=3000
|
||||
|
||||
# Интервал автозапуска парсера (в секундах, по умолчанию 1800 = 30 минут)
|
||||
PARSE_INTERVAL=1800
|
||||
|
||||
# Секрет для сессий (опционально, генерируется автоматически если не указан)
|
||||
# SESSION_SECRET=your_random_secret_here_64_chars
|
||||
|
||||
# Режим окружения (production включает secure cookie flag)
|
||||
# NODE_ENV=production
|
||||
@@ -1,5 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "[entrypoint] Building TypeScript..."
|
||||
npm run build
|
||||
|
||||
echo "[entrypoint] Starting web server..."
|
||||
exec npx ts-node src/server.ts
|
||||
exec npm run start:prod
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
"start:cli": "ts-node src/index.ts",
|
||||
"start:prod": "node dist/server.js",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
<div id="login-screen" class="screen">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo-icon"></div>
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>VPN Parser</h1>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
@@ -32,7 +36,11 @@
|
||||
<div id="dashboard-screen" class="screen hidden">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="logo-icon small"></div>
|
||||
<div class="logo-icon small">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="topbar-title">VPN Parser — Панель управления</span>
|
||||
</div>
|
||||
<button id="logout-btn" class="btn btn-ghost">Выход</button>
|
||||
@@ -51,11 +59,23 @@
|
||||
|
||||
<section class="card actions-card">
|
||||
<button id="run-btn" class="btn btn-success btn-lg">
|
||||
<span class="btn-icon">⟳</span>
|
||||
<span class="btn-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M23 4v6h-6"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
</span>
|
||||
Обновить сейчас
|
||||
</button>
|
||||
<button id="sync-xray2-btn" class="btn btn-secondary btn-lg">
|
||||
<span class="btn-icon">↻</span>
|
||||
<span class="btn-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||||
<path d="M16 21h5v-5"/>
|
||||
</svg>
|
||||
</span>
|
||||
Синхр. Xray 2
|
||||
</button>
|
||||
<div id="run-msg" class="msg hidden"></div>
|
||||
@@ -74,7 +94,14 @@
|
||||
<div class="status-grid">
|
||||
<div class="status-card" id="xray1-card">
|
||||
<div class="status-card-header">
|
||||
<span class="panel-icon xray-icon"></span>
|
||||
<span class="panel-icon xray-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2"/>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||
<line x1="6" y1="6" x2="6" y2="6"/>
|
||||
<line x1="6" y1="18" x2="6" y2="18"/>
|
||||
</svg>
|
||||
</span>
|
||||
<h3>Xray 1</h3>
|
||||
</div>
|
||||
<div class="status-badge" id="xray1-status">ожидание</div>
|
||||
@@ -83,7 +110,14 @@
|
||||
</div>
|
||||
<div class="status-card" id="xray2-card">
|
||||
<div class="status-card-header">
|
||||
<span class="panel-icon xray-icon"></span>
|
||||
<span class="panel-icon xray-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2"/>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||
<line x1="6" y1="6" x2="6" y2="6"/>
|
||||
<line x1="6" y1="18" x2="6" y2="18"/>
|
||||
</svg>
|
||||
</span>
|
||||
<h3>Xray 2</h3>
|
||||
</div>
|
||||
<div class="status-badge" id="xray2-status">ожидание</div>
|
||||
@@ -92,7 +126,11 @@
|
||||
</div>
|
||||
<div class="status-card" id="novavps-card">
|
||||
<div class="status-card-header">
|
||||
<span class="panel-icon novavps-icon"></span>
|
||||
<span class="panel-icon novavps-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<h3>Novavps</h3>
|
||||
</div>
|
||||
<div class="status-badge" id="novavps-status">ожидание</div>
|
||||
|
||||
@@ -34,6 +34,10 @@ body {
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
background-image:
|
||||
linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -43,7 +47,7 @@ body::before {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 400px;
|
||||
background: radial-gradient(ellipse at 50% 0%, rgba(99, 102, 241, 0.12) 0%, transparent 70%);
|
||||
background: radial-gradient(ellipse at 50% 0%, rgba(99, 102, 241, 0.15) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -101,12 +105,12 @@ body::before {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.logo-icon::after {
|
||||
content: "⚡";
|
||||
font-size: 18px;
|
||||
filter: brightness(2);
|
||||
.logo-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.logo-icon.small {
|
||||
@@ -115,8 +119,9 @@ body::before {
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
.logo-icon.small::after {
|
||||
font-size: 14px;
|
||||
.logo-icon.small svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -241,7 +246,14 @@ body::before {
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1.1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
@@ -293,7 +305,8 @@ body::before {
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 20px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
@@ -453,19 +466,26 @@ body::before {
|
||||
}
|
||||
|
||||
.panel-icon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.novavps-icon {
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 8px rgba(6, 182, 212, 0.5);
|
||||
color: var(--accent-cyan);
|
||||
filter: drop-shadow(0 0 6px rgba(6, 182, 212, 0.6));
|
||||
}
|
||||
|
||||
.xray-icon {
|
||||
background: var(--accent-amber);
|
||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
|
||||
color: var(--accent-amber);
|
||||
filter: drop-shadow(0 0 6px rgba(245, 158, 11, 0.6));
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@@ -487,11 +507,13 @@ body::before {
|
||||
.status-badge.success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--accent-green);
|
||||
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--accent-red);
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.status-detail {
|
||||
@@ -614,6 +636,7 @@ body::before {
|
||||
.synced-badge.yes {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--accent-green);
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.synced-badge.no {
|
||||
|
||||
33
src/config.ts
Normal file
33
src/config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface PanelConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function getPanels(): PanelConfig[] {
|
||||
const panels: PanelConfig[] = [];
|
||||
|
||||
const url1 = process.env.XRAY_URL;
|
||||
const user1 = process.env.XRAY_USERNAME;
|
||||
const pass1 = process.env.XRAY_PASSWORD;
|
||||
if (url1 && user1 && pass1) {
|
||||
panels.push({ url: url1, username: user1, password: pass1, label: "xray1" });
|
||||
}
|
||||
|
||||
const url2 = process.env.XRAY_URL2;
|
||||
const user2 = process.env.XRAY_USERNAME2;
|
||||
const pass2 = process.env.XRAY_PASSWORD2;
|
||||
if (url2 && user2 && pass2) {
|
||||
panels.push({ url: url2, username: user2, password: pass2, label: "xray2" });
|
||||
}
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
export function validCredentials(username: string, password: string): boolean {
|
||||
const panels = getPanels();
|
||||
return panels.some(
|
||||
(p) => p.username === username && p.password === password
|
||||
);
|
||||
}
|
||||
28
src/index.ts
28
src/index.ts
@@ -2,36 +2,10 @@ import dotenv from "dotenv";
|
||||
import { parseNovavps, getValidConnections } from "./novavps";
|
||||
import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel";
|
||||
import { closeBrowser } from "./browser";
|
||||
import { getPanels } from "./config";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
interface PanelConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function getPanels(): PanelConfig[] {
|
||||
const panels: PanelConfig[] = [];
|
||||
|
||||
const url1 = process.env.XRAY_URL;
|
||||
const user1 = process.env.XRAY_USERNAME;
|
||||
const pass1 = process.env.XRAY_PASSWORD;
|
||||
if (url1 && user1 && pass1) {
|
||||
panels.push({ url: url1, username: user1, password: pass1, label: "xray1" });
|
||||
}
|
||||
|
||||
const url2 = process.env.XRAY_URL2;
|
||||
const user2 = process.env.XRAY_USERNAME2;
|
||||
const pass2 = process.env.XRAY_PASSWORD2;
|
||||
if (url2 && user2 && pass2) {
|
||||
panels.push({ url: url2, username: user2, password: pass2, label: "xray2" });
|
||||
}
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const novavpsUrl = process.env.NOVAVPS_URL;
|
||||
|
||||
|
||||
31
src/ping.ts
31
src/ping.ts
@@ -13,25 +13,34 @@ export function tcpPing(host: string, port: number, timeout = 5000): Promise<num
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
const socket = new net.Socket();
|
||||
let resolved = false;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
socket.setTimeout(timeout);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
const resolveOnce = (value: number | null) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
resolveOnce(null);
|
||||
}, timeout);
|
||||
|
||||
socket.on("connect", () => {
|
||||
const latency = Date.now() - start;
|
||||
socket.destroy();
|
||||
resolve(latency);
|
||||
});
|
||||
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(null);
|
||||
resolveOnce(latency);
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
socket.destroy();
|
||||
resolve(null);
|
||||
resolveOnce(null);
|
||||
});
|
||||
|
||||
socket.connect(port, host);
|
||||
socket.connect({ port, host });
|
||||
});
|
||||
}
|
||||
|
||||
266
src/server.ts
266
src/server.ts
@@ -20,6 +20,7 @@ import {
|
||||
resetState,
|
||||
AppState,
|
||||
} from "./data-store";
|
||||
import { getPanels, validCredentials, PanelConfig } from "./config";
|
||||
|
||||
// Prometheus Metrics Initialization
|
||||
const prometheusRegistry = new promClient.Registry();
|
||||
@@ -54,42 +55,14 @@ const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toSt
|
||||
const SESSION_TTL = 60 * 60 * 1000;
|
||||
|
||||
const sessions = new Map<string, { createdAt: number; lastAccess: number }>();
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
const RATE_LIMIT_WINDOW = 60 * 1000;
|
||||
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(cookieParser());
|
||||
app.use(express.static(path.resolve(__dirname, "..", "public")));
|
||||
|
||||
interface PanelConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function getPanels(): PanelConfig[] {
|
||||
const panels: PanelConfig[] = [];
|
||||
const url1 = process.env.XRAY_URL;
|
||||
const user1 = process.env.XRAY_USERNAME;
|
||||
const pass1 = process.env.XRAY_PASSWORD;
|
||||
if (url1 && user1 && pass1) {
|
||||
panels.push({ url: url1, username: user1, password: pass1, label: "xray1" });
|
||||
}
|
||||
const url2 = process.env.XRAY_URL2;
|
||||
const user2 = process.env.XRAY_USERNAME2;
|
||||
const pass2 = process.env.XRAY_PASSWORD2;
|
||||
if (url2 && user2 && pass2) {
|
||||
panels.push({ url: url2, username: user2, password: pass2, label: "xray2" });
|
||||
}
|
||||
return panels;
|
||||
}
|
||||
|
||||
function validCredentials(username: string, password: string): boolean {
|
||||
const panels = getPanels();
|
||||
return panels.some(
|
||||
(p) => p.username === username && p.password === password
|
||||
);
|
||||
}
|
||||
|
||||
function createSession(): string {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const now = Date.now();
|
||||
@@ -128,6 +101,77 @@ function cleanupSessions() {
|
||||
}
|
||||
setInterval(cleanupSessions, 5 * 60 * 1000);
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, data] of loginAttempts.entries()) {
|
||||
if (now > data.resetAt) {
|
||||
loginAttempts.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
async function syncPanelWithPing(
|
||||
panel: PanelConfig,
|
||||
valid: NovavpsConnection[],
|
||||
state: AppState,
|
||||
stateKey: "xray1" | "xray2"
|
||||
): Promise<void> {
|
||||
if (valid.length === 0) return;
|
||||
|
||||
try {
|
||||
state.currentStep = stateKey === "xray1" ? "pinging" : "xray2_pinging";
|
||||
await saveState(state);
|
||||
|
||||
const pingResults: Array<{
|
||||
tag: string; name: string; address: string; port: string;
|
||||
latency: number | null; error: string | null;
|
||||
}> = [];
|
||||
for (let i = 0; i < valid.length; i++) {
|
||||
const conn = valid[i];
|
||||
const tag = `novavps${i + 1}`;
|
||||
const port = parseInt(conn.port) || 443;
|
||||
const latency = await tcpPing(conn.address, port);
|
||||
pingResults.push({
|
||||
tag, name: conn.name, address: conn.address, port: conn.port || String(port),
|
||||
latency, error: latency === null ? "Таймаут или ошибка" : null,
|
||||
});
|
||||
}
|
||||
state[stateKey].pingResults = pingResults;
|
||||
state[stateKey].allPingsOk = pingResults.every((p) => p.latency !== null);
|
||||
|
||||
let bestPingIndex = -1;
|
||||
let bestLatency = Infinity;
|
||||
pingResults.forEach((p, i) => {
|
||||
if (p.latency !== null && p.latency < bestLatency) {
|
||||
bestLatency = p.latency;
|
||||
bestPingIndex = i;
|
||||
}
|
||||
});
|
||||
|
||||
let syncConnections = [...valid];
|
||||
let syncedTags = valid.map((_, idx) => `novavps${idx + 1}`);
|
||||
if (bestPingIndex >= 0) {
|
||||
syncConnections = [valid[bestPingIndex], ...valid];
|
||||
syncedTags = ["novavps0", ...syncedTags];
|
||||
}
|
||||
|
||||
state.currentStep = stateKey === "xray1" ? "xray1_syncing" : "xray2_syncing";
|
||||
await saveState(state);
|
||||
|
||||
await syncNovavpsToXray(syncConnections, panel.url, panel.username, panel.password, panel.label, 0);
|
||||
state[stateKey].synced = true;
|
||||
state[stateKey].outboundsCount = syncedTags.length;
|
||||
state[stateKey].syncedOutbounds = syncedTags;
|
||||
panelStatus.set({ label: panel.label }, 1);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[${panel.label}] Ошибка синхронизации:`, errMsg);
|
||||
state[stateKey].error = errMsg;
|
||||
panelStatus.set({ label: panel.label }, 0);
|
||||
}
|
||||
await saveState(state);
|
||||
}
|
||||
|
||||
let isRunning = false;
|
||||
|
||||
async function runParser(): Promise<void> {
|
||||
@@ -247,121 +291,7 @@ async function runParser(): Promise<void> {
|
||||
if (valid.length === 0) continue;
|
||||
const panelStateKey = panel.label as "xray1" | "xray2";
|
||||
|
||||
// --- XRAY 1: ping + sync (все + novavps0) одним вызовом ---
|
||||
if (panel.label === "xray1") {
|
||||
try {
|
||||
state.currentStep = "pinging";
|
||||
await saveState(state);
|
||||
|
||||
const pingResults: Array<{
|
||||
tag: string; name: string; address: string; port: string;
|
||||
latency: number | null; error: string | null;
|
||||
}> = [];
|
||||
for (let i = 0; i < valid.length; i++) {
|
||||
const conn = valid[i];
|
||||
const tag = `novavps${i + 1}`;
|
||||
const port = parseInt(conn.port) || 443;
|
||||
const latency = await tcpPing(conn.address, port);
|
||||
pingResults.push({
|
||||
tag, name: conn.name, address: conn.address, port: conn.port || String(port),
|
||||
latency, error: latency === null ? "Таймаут или ошибка" : null,
|
||||
});
|
||||
}
|
||||
state.xray1.pingResults = pingResults;
|
||||
state.xray1.allPingsOk = pingResults.every((p) => p.latency !== null);
|
||||
|
||||
// Найти соединение с лучшим пингом
|
||||
let bestPingIndex = -1;
|
||||
let bestLatency = Infinity;
|
||||
pingResults.forEach((p, i) => {
|
||||
if (p.latency !== null && p.latency < bestLatency) {
|
||||
bestLatency = p.latency;
|
||||
bestPingIndex = i;
|
||||
}
|
||||
});
|
||||
|
||||
// Собрать массив: novavps0 (best) + novavps1..N
|
||||
let syncConnections = [...valid];
|
||||
let syncedTags = valid.map((_, idx) => `novavps${idx + 1}`);
|
||||
if (bestPingIndex >= 0) {
|
||||
syncConnections = [valid[bestPingIndex], ...valid];
|
||||
syncedTags = ["novavps0", ...syncedTags];
|
||||
}
|
||||
|
||||
state.currentStep = "xray1_syncing";
|
||||
await saveState(state);
|
||||
|
||||
await syncNovavpsToXray(syncConnections, panel.url, panel.username, panel.password, panel.label, 0);
|
||||
state.xray1.synced = true;
|
||||
state.xray1.outboundsCount = syncedTags.length;
|
||||
state.xray1.syncedOutbounds = syncedTags;
|
||||
panelStatus.set({ label: "xray1" }, 1);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error("[xray1] Ошибка синхронизации:", errMsg);
|
||||
state.xray1.error = errMsg;
|
||||
panelStatus.set({ label: "xray1" }, 0);
|
||||
}
|
||||
await saveState(state);
|
||||
}
|
||||
|
||||
// --- XRAY 2: ping + sync (все + novavps0) одним вызовом ---
|
||||
if (panel.label === "xray2") {
|
||||
try {
|
||||
state.currentStep = "xray2_pinging";
|
||||
await saveState(state);
|
||||
|
||||
const pingResults: Array<{
|
||||
tag: string; name: string; address: string; port: string;
|
||||
latency: number | null; error: string | null;
|
||||
}> = [];
|
||||
for (let i = 0; i < valid.length; i++) {
|
||||
const conn = valid[i];
|
||||
const tag = `novavps${i + 1}`;
|
||||
const port = parseInt(conn.port) || 443;
|
||||
const latency = await tcpPing(conn.address, port);
|
||||
pingResults.push({
|
||||
tag, name: conn.name, address: conn.address, port: conn.port || String(port),
|
||||
latency, error: latency === null ? "Таймаут или ошибка" : null,
|
||||
});
|
||||
}
|
||||
state.xray2.pingResults = pingResults;
|
||||
state.xray2.allPingsOk = pingResults.every((p) => p.latency !== null);
|
||||
|
||||
// Найти соединение с лучшим пингом
|
||||
let bestPingIndex = -1;
|
||||
let bestLatency = Infinity;
|
||||
pingResults.forEach((p, i) => {
|
||||
if (p.latency !== null && p.latency < bestLatency) {
|
||||
bestLatency = p.latency;
|
||||
bestPingIndex = i;
|
||||
}
|
||||
});
|
||||
|
||||
// Собрать массив: novavps0 (best) + novavps1..N
|
||||
let syncConnections = [...valid];
|
||||
let syncedTags = valid.map((_, idx) => `novavps${idx + 1}`);
|
||||
if (bestPingIndex >= 0) {
|
||||
syncConnections = [valid[bestPingIndex], ...valid];
|
||||
syncedTags = ["novavps0", ...syncedTags];
|
||||
}
|
||||
|
||||
state.currentStep = "xray2_syncing";
|
||||
await saveState(state);
|
||||
|
||||
await syncNovavpsToXray(syncConnections, panel.url, panel.username, panel.password, panel.label, 0);
|
||||
state.xray2.synced = true;
|
||||
state.xray2.outboundsCount = syncedTags.length;
|
||||
state.xray2.syncedOutbounds = syncedTags;
|
||||
panelStatus.set({ label: "xray2" }, 1);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error("[xray2] Ошибка синхронизации:", errMsg);
|
||||
state.xray2.error = errMsg;
|
||||
panelStatus.set({ label: "xray2" }, 0);
|
||||
}
|
||||
await saveState(state);
|
||||
}
|
||||
await syncPanelWithPing(panel, valid, state, panelStateKey);
|
||||
}
|
||||
|
||||
state.status = "completed";
|
||||
@@ -486,6 +416,10 @@ app.post("/api/sync-xray2", authMiddleware, async (_req: Request, res: Response)
|
||||
res.status(409).json({ error: "Синхронизация Xray2 уже запущена" });
|
||||
return;
|
||||
}
|
||||
if (isRunning) {
|
||||
res.status(409).json({ error: "Парсер запущен, дождитесь завершения" });
|
||||
return;
|
||||
}
|
||||
const panel = getPanels().find((p) => p.label === "xray2");
|
||||
if (!panel) {
|
||||
console.warn("[api/sync-xray2] Xray2 панель не настроена");
|
||||
@@ -497,19 +431,45 @@ app.post("/api/sync-xray2", authMiddleware, async (_req: Request, res: Response)
|
||||
});
|
||||
|
||||
app.post("/api/login", async (req: Request, res: Response) => {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const now = Date.now();
|
||||
const attempt = loginAttempts.get(ip);
|
||||
|
||||
if (attempt && now < attempt.resetAt) {
|
||||
if (attempt.count >= RATE_LIMIT_MAX) {
|
||||
const retryAfter = Math.ceil((attempt.resetAt - now) / 1000);
|
||||
res.status(429).json({ error: `Слишком много попыток. Попробуйте через ${retryAfter}с` });
|
||||
return;
|
||||
}
|
||||
attempt.count++;
|
||||
} else {
|
||||
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: "username and password required" });
|
||||
return;
|
||||
}
|
||||
if (typeof username !== "string" || typeof password !== "string") {
|
||||
res.status(400).json({ error: "username and password must be strings" });
|
||||
return;
|
||||
}
|
||||
if (username.length < 1 || username.length > 100 || password.length < 1 || password.length > 200) {
|
||||
res.status(400).json({ error: "username or password length invalid" });
|
||||
return;
|
||||
}
|
||||
if (!validCredentials(username, password)) {
|
||||
res.status(401).json({ error: "Invalid credentials" });
|
||||
return;
|
||||
}
|
||||
|
||||
loginAttempts.delete(ip);
|
||||
const token = createSession();
|
||||
res.cookie("session", token, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: SESSION_TTL,
|
||||
path: "/",
|
||||
});
|
||||
@@ -549,6 +509,16 @@ app.put("/api/config", authMiddleware, async (req: Request, res: Response) => {
|
||||
res.status(400).json({ error: "novavpsUrl required" });
|
||||
return;
|
||||
}
|
||||
if (typeof novavpsUrl !== "string" || novavpsUrl.length < 10 || novavpsUrl.length > 500) {
|
||||
res.status(400).json({ error: "novavpsUrl must be a string between 10 and 500 characters" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(novavpsUrl);
|
||||
} catch {
|
||||
res.status(400).json({ error: "novavpsUrl must be a valid URL" });
|
||||
return;
|
||||
}
|
||||
const config = await loadConfig(process.env.NOVAVPS_URL || "");
|
||||
config.novavpsUrl = novavpsUrl;
|
||||
await saveConfig(config);
|
||||
@@ -580,6 +550,10 @@ app.get("/api/metrics", authMiddleware, async (_req: Request, res: Response) =>
|
||||
res.end(prometheusRegistry.metrics());
|
||||
});
|
||||
|
||||
app.get("/api/health", (_req: Request, res: Response) => {
|
||||
res.json({ ok: true, uptime: process.uptime() });
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.error("[server] Unhandled error:", err);
|
||||
|
||||
Reference in New Issue
Block a user