в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
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
echo "[entrypoint] Building TypeScript..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
echo "[entrypoint] Starting web server..."
|
echo "[entrypoint] Starting web server..."
|
||||||
exec npx ts-node src/server.ts
|
exec npm run start:prod
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "ts-node src/server.ts",
|
"start": "ts-node src/server.ts",
|
||||||
"start:cli": "ts-node src/index.ts",
|
"start:cli": "ts-node src/index.ts",
|
||||||
|
"start:prod": "node dist/server.js",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,11 @@
|
|||||||
<div id="login-screen" class="screen">
|
<div id="login-screen" class="screen">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-header">
|
<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>
|
<h1>VPN Parser</h1>
|
||||||
</div>
|
</div>
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
@@ -32,7 +36,11 @@
|
|||||||
<div id="dashboard-screen" class="screen hidden">
|
<div id="dashboard-screen" class="screen hidden">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-left">
|
<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>
|
<span class="topbar-title">VPN Parser — Панель управления</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="logout-btn" class="btn btn-ghost">Выход</button>
|
<button id="logout-btn" class="btn btn-ghost">Выход</button>
|
||||||
@@ -51,11 +59,23 @@
|
|||||||
|
|
||||||
<section class="card actions-card">
|
<section class="card actions-card">
|
||||||
<button id="run-btn" class="btn btn-success btn-lg">
|
<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>
|
||||||
<button id="sync-xray2-btn" class="btn btn-secondary btn-lg">
|
<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
|
Синхр. Xray 2
|
||||||
</button>
|
</button>
|
||||||
<div id="run-msg" class="msg hidden"></div>
|
<div id="run-msg" class="msg hidden"></div>
|
||||||
@@ -74,7 +94,14 @@
|
|||||||
<div class="status-grid">
|
<div class="status-grid">
|
||||||
<div class="status-card" id="xray1-card">
|
<div class="status-card" id="xray1-card">
|
||||||
<div class="status-card-header">
|
<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>
|
<h3>Xray 1</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-badge" id="xray1-status">ожидание</div>
|
<div class="status-badge" id="xray1-status">ожидание</div>
|
||||||
@@ -83,7 +110,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="status-card" id="xray2-card">
|
<div class="status-card" id="xray2-card">
|
||||||
<div class="status-card-header">
|
<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>
|
<h3>Xray 2</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-badge" id="xray2-status">ожидание</div>
|
<div class="status-badge" id="xray2-status">ожидание</div>
|
||||||
@@ -92,7 +126,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="status-card" id="novavps-card">
|
<div class="status-card" id="novavps-card">
|
||||||
<div class="status-card-header">
|
<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>
|
<h3>Novavps</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-badge" id="novavps-status">ожидание</div>
|
<div class="status-badge" id="novavps-status">ожидание</div>
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
line-height: 1.5;
|
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 {
|
body::before {
|
||||||
@@ -43,7 +47,7 @@ body::before {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 400px;
|
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;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -101,12 +105,12 @@ body::before {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon::after {
|
.logo-icon svg {
|
||||||
content: "⚡";
|
width: 20px;
|
||||||
font-size: 18px;
|
height: 20px;
|
||||||
filter: brightness(2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon.small {
|
.logo-icon.small {
|
||||||
@@ -115,8 +119,9 @@ body::before {
|
|||||||
border-radius: var(--radius-xs);
|
border-radius: var(--radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon.small::after {
|
.logo-icon.small svg {
|
||||||
font-size: 14px;
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
@@ -241,7 +246,14 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
font-size: 1.1em;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Topbar */
|
/* Topbar */
|
||||||
@@ -293,7 +305,8 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.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 {
|
.card h2 {
|
||||||
@@ -453,19 +466,26 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-icon {
|
.panel-icon {
|
||||||
width: 8px;
|
width: 20px;
|
||||||
height: 8px;
|
height: 20px;
|
||||||
border-radius: 50%;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.novavps-icon {
|
.novavps-icon {
|
||||||
background: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
box-shadow: 0 0 8px rgba(6, 182, 212, 0.5);
|
filter: drop-shadow(0 0 6px rgba(6, 182, 212, 0.6));
|
||||||
}
|
}
|
||||||
|
|
||||||
.xray-icon {
|
.xray-icon {
|
||||||
background: var(--accent-amber);
|
color: var(--accent-amber);
|
||||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
|
filter: drop-shadow(0 0 6px rgba(245, 158, 11, 0.6));
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
@@ -487,11 +507,13 @@ body::before {
|
|||||||
.status-badge.success {
|
.status-badge.success {
|
||||||
background: rgba(34, 197, 94, 0.15);
|
background: rgba(34, 197, 94, 0.15);
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
|
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.error {
|
.status-badge.error {
|
||||||
background: rgba(239, 68, 68, 0.15);
|
background: rgba(239, 68, 68, 0.15);
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-detail {
|
.status-detail {
|
||||||
@@ -614,6 +636,7 @@ body::before {
|
|||||||
.synced-badge.yes {
|
.synced-badge.yes {
|
||||||
background: rgba(34, 197, 94, 0.15);
|
background: rgba(34, 197, 94, 0.15);
|
||||||
color: var(--accent-green);
|
color: var(--accent-green);
|
||||||
|
box-shadow: 0 0 8px rgba(34, 197, 94, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.synced-badge.no {
|
.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 { parseNovavps, getValidConnections } from "./novavps";
|
||||||
import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel";
|
import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel";
|
||||||
import { closeBrowser } from "./browser";
|
import { closeBrowser } from "./browser";
|
||||||
|
import { getPanels } from "./config";
|
||||||
|
|
||||||
dotenv.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() {
|
async function main() {
|
||||||
const novavpsUrl = process.env.NOVAVPS_URL;
|
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) => {
|
return new Promise((resolve) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const socket = new net.Socket();
|
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", () => {
|
socket.on("connect", () => {
|
||||||
const latency = Date.now() - start;
|
const latency = Date.now() - start;
|
||||||
socket.destroy();
|
resolveOnce(latency);
|
||||||
resolve(latency);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on("timeout", () => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve(null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("error", () => {
|
socket.on("error", () => {
|
||||||
socket.destroy();
|
resolveOnce(null);
|
||||||
resolve(null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.connect(port, host);
|
socket.connect({ port, host });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
266
src/server.ts
266
src/server.ts
@@ -20,6 +20,7 @@ import {
|
|||||||
resetState,
|
resetState,
|
||||||
AppState,
|
AppState,
|
||||||
} from "./data-store";
|
} from "./data-store";
|
||||||
|
import { getPanels, validCredentials, PanelConfig } from "./config";
|
||||||
|
|
||||||
// Prometheus Metrics Initialization
|
// Prometheus Metrics Initialization
|
||||||
const prometheusRegistry = new promClient.Registry();
|
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 SESSION_TTL = 60 * 60 * 1000;
|
||||||
|
|
||||||
const sessions = new Map<string, { createdAt: number; lastAccess: number }>();
|
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(express.json({ limit: "1mb" }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(express.static(path.resolve(__dirname, "..", "public")));
|
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 {
|
function createSession(): string {
|
||||||
const token = crypto.randomBytes(32).toString("hex");
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -128,6 +101,77 @@ function cleanupSessions() {
|
|||||||
}
|
}
|
||||||
setInterval(cleanupSessions, 5 * 60 * 1000);
|
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;
|
let isRunning = false;
|
||||||
|
|
||||||
async function runParser(): Promise<void> {
|
async function runParser(): Promise<void> {
|
||||||
@@ -247,121 +291,7 @@ async function runParser(): Promise<void> {
|
|||||||
if (valid.length === 0) continue;
|
if (valid.length === 0) continue;
|
||||||
const panelStateKey = panel.label as "xray1" | "xray2";
|
const panelStateKey = panel.label as "xray1" | "xray2";
|
||||||
|
|
||||||
// --- XRAY 1: ping + sync (все + novavps0) одним вызовом ---
|
await syncPanelWithPing(panel, valid, state, panelStateKey);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.status = "completed";
|
state.status = "completed";
|
||||||
@@ -486,6 +416,10 @@ app.post("/api/sync-xray2", authMiddleware, async (_req: Request, res: Response)
|
|||||||
res.status(409).json({ error: "Синхронизация Xray2 уже запущена" });
|
res.status(409).json({ error: "Синхронизация Xray2 уже запущена" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isRunning) {
|
||||||
|
res.status(409).json({ error: "Парсер запущен, дождитесь завершения" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const panel = getPanels().find((p) => p.label === "xray2");
|
const panel = getPanels().find((p) => p.label === "xray2");
|
||||||
if (!panel) {
|
if (!panel) {
|
||||||
console.warn("[api/sync-xray2] Xray2 панель не настроена");
|
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) => {
|
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;
|
const { username, password } = req.body;
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
res.status(400).json({ error: "username and password required" });
|
res.status(400).json({ error: "username and password required" });
|
||||||
return;
|
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)) {
|
if (!validCredentials(username, password)) {
|
||||||
res.status(401).json({ error: "Invalid credentials" });
|
res.status(401).json({ error: "Invalid credentials" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginAttempts.delete(ip);
|
||||||
const token = createSession();
|
const token = createSession();
|
||||||
res.cookie("session", token, {
|
res.cookie("session", token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
maxAge: SESSION_TTL,
|
maxAge: SESSION_TTL,
|
||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
@@ -549,6 +509,16 @@ app.put("/api/config", authMiddleware, async (req: Request, res: Response) => {
|
|||||||
res.status(400).json({ error: "novavpsUrl required" });
|
res.status(400).json({ error: "novavpsUrl required" });
|
||||||
return;
|
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 || "");
|
const config = await loadConfig(process.env.NOVAVPS_URL || "");
|
||||||
config.novavpsUrl = novavpsUrl;
|
config.novavpsUrl = novavpsUrl;
|
||||||
await saveConfig(config);
|
await saveConfig(config);
|
||||||
@@ -580,6 +550,10 @@ app.get("/api/metrics", authMiddleware, async (_req: Request, res: Response) =>
|
|||||||
res.end(prometheusRegistry.metrics());
|
res.end(prometheusRegistry.metrics());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/health", (_req: Request, res: Response) => {
|
||||||
|
res.json({ ok: true, uptime: process.uptime() });
|
||||||
|
});
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
console.error("[server] Unhandled error:", err);
|
console.error("[server] Unhandled error:", err);
|
||||||
|
|||||||
Reference in New Issue
Block a user