ожидание
diff --git a/public/style.css b/public/style.css
index dbfe793..af52e99 100644
--- a/public/style.css
+++ b/public/style.css
@@ -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 {
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..8973378
--- /dev/null
+++ b/src/config.ts
@@ -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
+ );
+}
diff --git a/src/index.ts b/src/index.ts
index 5f2fc21..9bcc1a5 100644
--- a/src/index.ts
+++ b/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;
diff --git a/src/ping.ts b/src/ping.ts
index 9120e10..2e7f221 100644
--- a/src/ping.ts
+++ b/src/ping.ts
@@ -13,25 +13,34 @@ export function tcpPing(host: string, port: number, timeout = 5000): Promise
{
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 });
});
}
diff --git a/src/server.ts b/src/server.ts
index f5a89f7..409e244 100644
--- a/src/server.ts
+++ b/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();
+const loginAttempts = new Map();
+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 {
+ 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 {
@@ -247,121 +291,7 @@ async function runParser(): Promise {
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);