This commit is contained in:
2026-06-04 00:14:54 +05:00
parent a0a6135d2c
commit 85afc04ce2
11 changed files with 178 additions and 411 deletions

View File

@@ -1,16 +1,14 @@
# NOVAVPS - ссылка на страницу с подключениями
NOVAVPS_URL=https://novavps.app/s/your_link_here
# Ссылка на страницу подписки (Novavps или другой сервис)
subscription_url=https://novavps.app/s/your_link_here
# Панель Xray 1 (основная)
# Префикс для имён подключений в Xray (по умолчанию novavps)
name_url=nova
# Панель Xray
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

View File

@@ -6,19 +6,15 @@ services:
- TZ=Asia/Yekaterinburg
- PARSE_INTERVAL=1800
# ---------- Парсер novavps ----------
- NOVAVPS_URL=
# ---------- Парсер подписки ----------
- subscription_url=
- name_url=nova
# ---------- Панель Xray 1 ----------
# ---------- Панель Xray ----------
- XRAY_URL=
- XRAY_USERNAME=
- XRAY_PASSWORD=
# ---------- Панель Xray 2 ----------
- XRAY_URL2=
- XRAY_USERNAME2=
- XRAY_PASSWORD2=
ports:
- "3000:3000"
networks:

View File

@@ -5,16 +5,14 @@ const dashboardScreen = $("#dashboard-screen");
const loginForm = $("#login-form");
const loginError = $("#login-error");
const logoutBtn = $("#logout-btn");
const novavpsUrlInput = $("#novavps-url");
const subscriptionUrlInput = $("#subscription-url");
const saveConfigBtn = $("#save-config-btn");
const configMsg = $("#config-msg");
const runBtn = $("#run-btn");
const syncXray2Btn = $("#sync-xray2-btn");
const runMsg = $("#run-msg");
const runningIndicator = $("#running-indicator");
const currentStep = $("#current-step");
const lastRunEl = $("#last-run");
const xray2Section = $("#xray2-data-section");
const POLL_INTERVAL = 10000;
@@ -42,16 +40,13 @@ function formatTime(iso) {
const stepLabels = {
initializing: "Инициализация...",
novavps_fetching: "Получение данных NOVAVPS...",
xray1_parsing: "Чтение панели Xray 1...",
xray1_syncing: "Синхронизация Xray 1...",
pinging: "Проверка соединений (Xray 1)...",
xray2_parsing: "Чтение панели Xray 2...",
xray2_syncing: "Синхронизация Xray 2...",
xray2_pinging: "Проверка соединений (Xray 2)...",
novavps_fetching: "Получение данных подписки...",
parsing: "Чтение панели Xray...",
syncing: "Синхронизация Xray...",
pinging: "Проверка соединений (Xray)...",
done: "Завершено",
novavps_url_missing: "Ошибка: ссылка NOVAVPS не задана",
no_xray_panels: "Ошибка: нет настроенных панелей Xray",
subscription_url_missing: "Ошибка: ссылка подписки не задана",
no_xray_panel: "Ошибка: не настроена панель Xray",
fatal: "Критическая ошибка",
};
@@ -94,26 +89,18 @@ async function fetchStatus() {
const data = await res.json();
const isRunning = data.status === "running";
const isXray2Syncing = data.currentStep === "xray2_syncing";
runBtn.disabled = isRunning;
syncXray2Btn.disabled = isRunning || isXray2Syncing;
runningIndicator.classList.toggle("hidden", !isRunning);
currentStep.textContent = stepLabels[data.currentStep] || data.currentStep;
const hasXray2 = data.configuredPanels && data.configuredPanels.includes("xray2");
syncXray2Btn.classList.toggle("hidden", !hasXray2);
updatePanelStatus("novavps", data.novavps);
updatePanelStatus("xray1", data.xray1);
updatePanelStatus("xray2", data.xray2);
updatePanelStatus("xray", data.xray);
pingResults = data.xray1?.pingResults || [];
pingResults = data.xray?.pingResults || [];
lastRunEl.textContent = data.lastRun ? formatTime(data.lastRun) : "Никогда";
if (data.xray2.status !== "idle" || data.xray2.outboundsCount > 0) {
xray2Section.classList.remove("hidden");
}
} catch (err) {
console.error("Status fetch error:", err);
}
@@ -128,7 +115,7 @@ async function fetchConfig() {
return;
}
const data = await res.json();
novavpsUrlInput.value = data.novavpsUrl || "";
subscriptionUrlInput.value = data.subscriptionUrl || "";
} catch (err) {
console.error("Config fetch error:", err);
}
@@ -144,19 +131,13 @@ async function fetchNovavpsData() {
} catch {}
}
async function fetchXrayData(panel, syncedTags) {
async function fetchXrayData(syncedTags) {
try {
const res = await fetch("/api/data/xray");
if (res.status === 401) return;
const data = await res.json();
if (panel === "xray1") {
renderXrayTable("xray1", data, syncedTags || [], pingResults);
$("#xray1-data-count").textContent = data.length;
} else if (panel === "xray2") {
renderXrayTable("xray2", data, syncedTags || [], []);
$("#xray2-data-count").textContent = data.length;
}
renderXrayTable("xray", data, syncedTags || [], pingResults);
$("#xray-data-count").textContent = data.length;
} catch {}
}
@@ -180,8 +161,7 @@ function renderNovavpsTable(connections) {
function renderXrayTable(prefix, outbounds, syncedTags, pingList) {
const tbody = $(`#${prefix}-tbody`);
const hasLatency = prefix === "xray1";
const colspan = hasLatency ? 6 : 5;
const colspan = 6;
if (!outbounds.length) {
tbody.innerHTML = `<tr><td colspan="${colspan}" style="color:var(--text-muted);text-align:center;padding:2rem;">Нет данных</td></tr>`;
@@ -203,11 +183,11 @@ function renderXrayTable(prefix, outbounds, syncedTags, pingList) {
: '<span class="synced-badge no">—</span>';
let latencyCell = '<span class="latency-cell latency-error">—</span>';
if (hasLatency && pingMap[tag]) {
if (pingMap[tag]) {
latencyCell = formatLatency(pingMap[tag].latency);
}
const latencyCol = hasLatency ? `<td>${latencyCell}</td>` : "";
const latencyCol = `<td>${latencyCell}</td>`;
return `<tr><td>${tag}</td><td>${proto}</td><td>${addr}</td><td>${port}</td>${latencyCol}<td>${syncBadge}</td></tr>`;
})
.join("");
@@ -223,14 +203,12 @@ function startPolling() {
fetchStatus();
fetchConfig();
fetchNovavpsData();
fetchXrayData("xray1", []);
fetchXrayData("xray2", []);
fetchXrayData([]);
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => {
fetchStatus();
fetchNovavpsData();
fetchXrayData("xray1", []);
fetchXrayData("xray2", []);
fetchXrayData([]);
}, POLL_INTERVAL);
}
@@ -295,7 +273,7 @@ saveConfigBtn.addEventListener("click", async () => {
const res = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ novavpsUrl: novavpsUrlInput.value }),
body: JSON.stringify({ subscriptionUrl: subscriptionUrlInput.value }),
});
const data = await res.json();
if (!res.ok) {
@@ -324,22 +302,6 @@ runBtn.addEventListener("click", async () => {
}
});
syncXray2Btn.addEventListener("click", async () => {
runMsg.classList.add("hidden");
try {
const res = await fetch("/api/sync-xray2", { method: "POST" });
const data = await res.json();
if (!res.ok) {
showMsg(runMsg, data.error || "Ошибка запуска", "error");
return;
}
showMsg(runMsg, "Синхронизация Xray2 запущена", "success");
fetchStatus();
} catch (err) {
showMsg(runMsg, "Ошибка сети", "error");
}
});
fetch("/api/status")
.then((res) => {
if (res.ok) {

View File

@@ -50,8 +50,8 @@
<section class="card config-card">
<h2>Настройки</h2>
<div class="config-row">
<label for="novavps-url">Ссылка NOVAVPS</label>
<input type="text" id="novavps-url" placeholder="https://novavps.app/s/...">
<label for="subscription-url">Ссылка подписки</label>
<input type="text" id="subscription-url" placeholder="https://novavps.app/s/...">
<button id="save-config-btn" class="btn btn-primary">Сохранить</button>
</div>
<div id="config-msg" class="msg hidden"></div>
@@ -67,17 +67,7 @@
</span>
Обновить сейчас
</button>
<button id="sync-xray2-btn" class="btn btn-secondary btn-lg">
<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>
</section>
@@ -92,7 +82,7 @@
<section class="status-section">
<h2 class="section-title">Статус панелей</h2>
<div class="status-grid">
<div class="status-card" id="xray1-card">
<div class="status-card" id="xray-card">
<div class="status-card-header">
<span class="panel-icon xray-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -102,27 +92,11 @@
<line x1="6" y1="18" x2="6" y2="18"/>
</svg>
</span>
<h3>Xray 1</h3>
<h3>Xray</h3>
</div>
<div class="status-badge" id="xray1-status">ожидание</div>
<div class="status-detail" id="xray1-detail"></div>
<div class="status-time" id="xray1-time"></div>
</div>
<div class="status-card" id="xray2-card">
<div class="status-card-header">
<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>
<div class="status-detail" id="xray2-detail"></div>
<div class="status-time" id="xray2-time"></div>
<div class="status-badge" id="xray-status">ожидание</div>
<div class="status-detail" id="xray-detail"></div>
<div class="status-time" id="xray-time"></div>
</div>
<div class="status-card" id="novavps-card">
<div class="status-card-header">
@@ -140,15 +114,15 @@
</div>
</section>
<section class="card data-section" id="xray1-data-section">
<div class="data-section-header" onclick="toggleSection('xray1-data')">
<h2>Исходящие Xray 1</h2>
<span class="data-count" id="xray1-data-count"></span>
<span class="collapse-arrow" id="xray1-data-arrow"></span>
<section class="card data-section" id="xray-data-section">
<div class="data-section-header" onclick="toggleSection('xray-data')">
<h2>Исходящие Xray</h2>
<span class="data-count" id="xray-data-count"></span>
<span class="collapse-arrow" id="xray-data-arrow"></span>
</div>
<div class="data-content" id="xray1-data">
<div class="data-content" id="xray-data">
<div class="table-wrapper">
<table class="data-table" id="xray1-table">
<table class="data-table" id="xray-table">
<thead>
<tr>
<th>Метка</th>
@@ -159,31 +133,7 @@
<th>Синхр.</th>
</tr>
</thead>
<tbody id="xray1-tbody"></tbody>
</table>
</div>
</div>
</section>
<section class="card data-section hidden" id="xray2-data-section">
<div class="data-section-header" onclick="toggleSection('xray2-data')">
<h2>Исходящие Xray 2</h2>
<span class="data-count" id="xray2-data-count"></span>
<span class="collapse-arrow" id="xray2-data-arrow"></span>
</div>
<div class="data-content" id="xray2-data">
<div class="table-wrapper">
<table class="data-table" id="xray2-table">
<thead>
<tr>
<th>Метка</th>
<th>Протокол</th>
<th>Адрес</th>
<th>Порт</th>
<th>Синхр.</th>
</tr>
</thead>
<tbody id="xray2-tbody"></tbody>
<tbody id="xray-tbody"></tbody>
</table>
</div>
</div>

View File

@@ -3,7 +3,10 @@ import { chromium, Browser, BrowserContext } from "playwright";
let browser: Browser | null = null;
export async function getBrowser(): Promise<Browser> {
if (!browser) {
if (!browser || !browser.isConnected()) {
if (browser) {
try { await browser.close().catch(() => {}); } catch {}
}
browser = await chromium.launch({ headless: true });
const exitHandler = () => browser?.close().catch(() => {});
process.on("exit", exitHandler);

View File

@@ -5,29 +5,21 @@ export interface PanelConfig {
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" });
export function getPanel(): PanelConfig | null {
const url = process.env.XRAY_URL;
const user = process.env.XRAY_USERNAME;
const pass = process.env.XRAY_PASSWORD;
if (url && user && pass) {
return { url, username: user, password: pass, label: "xray" };
}
return null;
}
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 getConnectionNamePrefix(): string {
return process.env.name_url || "novavps";
}
export function validCredentials(username: string, password: string): boolean {
const panels = getPanels();
return panels.some(
(p) => p.username === username && p.password === password
);
const panel = getPanel();
return panel !== null && panel.username === username && panel.password === password;
}

View File

@@ -5,7 +5,7 @@ import { existsSync } from "fs";
const DATA_DIR = path.resolve(__dirname, "..", "data");
export interface AppConfig {
novavpsUrl: string;
subscriptionUrl: string;
}
export interface PingResult {
@@ -34,15 +34,14 @@ export interface AppState {
status: "idle" | "running" | "completed" | "error";
currentStep: string;
novavps: PanelStatus;
xray1: PanelStatus;
xray2: PanelStatus;
xray: PanelStatus;
}
const CONFIG_FILE = path.join(DATA_DIR, "config.json");
const STATE_FILE = path.join(DATA_DIR, "state.json");
const defaultConfig: AppConfig = {
novavpsUrl: "",
subscriptionUrl: "",
};
const defaultState: AppState = {
@@ -50,8 +49,7 @@ const defaultState: AppState = {
status: "idle",
currentStep: "",
novavps: { status: "idle", count: 0, timestamp: null, error: null },
xray1: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], pingResults: [], allPingsOk: false, timestamp: null, error: null },
xray2: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], timestamp: null, error: null },
xray: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], pingResults: [], allPingsOk: false, timestamp: null, error: null },
};
async function ensureDataDir(): Promise<void> {
@@ -73,10 +71,10 @@ async function writeJson<T>(filePath: string, data: T): Promise<void> {
await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
}
export async function loadConfig(envNovavpsUrl: string): Promise<AppConfig> {
export async function loadConfig(envSubscriptionUrl: string): Promise<AppConfig> {
const saved = await readJson<AppConfig>(CONFIG_FILE, defaultConfig);
if (!saved.novavpsUrl && envNovavpsUrl) {
saved.novavpsUrl = envNovavpsUrl;
if (!saved.subscriptionUrl && envSubscriptionUrl) {
saved.subscriptionUrl = envSubscriptionUrl;
await writeJson(CONFIG_FILE, saved);
}
return saved;
@@ -101,8 +99,7 @@ export async function resetState(): Promise<AppState> {
status: "running",
currentStep: "initializing",
novavps: { status: "idle", count: 0, timestamp: null, error: null },
xray1: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], pingResults: [], allPingsOk: false, timestamp: null, error: null },
xray2: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], timestamp: null, error: null },
xray: { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], pingResults: [], allPingsOk: false, timestamp: null, error: null },
};
await saveState(state);
return state;

View File

@@ -2,21 +2,21 @@ import dotenv from "dotenv";
import { parseNovavps, getValidConnections } from "./novavps";
import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel";
import { closeBrowser } from "./browser";
import { getPanels } from "./config";
import { getPanel } from "./config";
dotenv.config();
async function main() {
const novavpsUrl = process.env.NOVAVPS_URL;
const novavpsUrl = process.env.subscription_url;
if (!novavpsUrl) {
console.error("NOVAVPS_URL not set in .env");
console.error("subscription_url not set in .env");
process.exit(1);
}
const panels = getPanels();
if (panels.length === 0) {
console.error("Нет настроенных Xray панелей (XRAY_URL или XRAY_URL2)");
const panel = getPanel();
if (!panel) {
console.error("Нет настроенной Xray панели (XRAY_URL)");
process.exit(1);
}
@@ -33,30 +33,26 @@ async function main() {
const valid = getValidConnections(novavpsConnections);
console.log(`[result] valid (без auto): ${valid.length} connections\n`);
for (const panel of panels) {
console.log(`--- ${panel.label}: ${panel.url} ---`);
try {
const xray = await parseXrayPanel(panel.url, panel.username, panel.password);
console.log(`[${panel.label}] Найдено outbounds: ${xray.length}`);
} catch (err) {
console.error(`[${panel.label}] Ошибка парсинга:`, err instanceof Error ? err.message : String(err));
}
console.log();
if (valid.length > 0) {
try {
await syncNovavpsToXray(valid, panel.url, panel.username, panel.password, panel.label);
console.log(`[${panel.label}] Синхронизация завершена`);
} catch (err) {
console.error(`[${panel.label}] Ошибка синхронизации:`, err instanceof Error ? err.message : String(err));
}
}
console.log();
console.log(`--- ${panel.label}: ${panel.url} ---`);
try {
const xray = await parseXrayPanel(panel.url, panel.username, panel.password);
console.log(`[${panel.label}] Найдено outbounds: ${xray.length}`);
} catch (err) {
console.error(`[${panel.label}] Ошибка парсинга:`, err instanceof Error ? err.message : String(err));
}
console.log();
if (valid.length > 0) {
try {
await syncNovavpsToXray(valid, panel.url, panel.username, panel.password, panel.label);
console.log(`[${panel.label}] Синхронизация завершена`);
} catch (err) {
console.error(`[${panel.label}] Ошибка синхронизации:`, err instanceof Error ? err.message : String(err));
}
}
console.log();
await closeBrowser();
console.log("Done.");
}

View File

@@ -143,7 +143,7 @@ export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
await page.goto(url, { waitUntil: "load", timeout: 60000 });
await page.waitForTimeout(3000);
const linksJson = await page.evaluate(() => {

View File

@@ -20,7 +20,7 @@ import {
resetState,
AppState,
} from "./data-store";
import { getPanels, validCredentials, PanelConfig } from "./config";
import { getPanel, getConnectionNamePrefix, validCredentials, PanelConfig } from "./config";
// Prometheus Metrics Initialization
const prometheusRegistry = new promClient.Registry();
@@ -31,12 +31,6 @@ const parserRunsTotal = new promClient.Counter({
labelNames: ['status'],
});
const xray2SyncRunsTotal = new promClient.Counter({
name: 'xray2_sync_runs_total',
help: 'Total attempts to synchronize Xray2 panel settings.',
labelNames: ['status'],
});
const novavpsConnectionsCount = new promClient.Gauge({
name: 'novavps_connections_count',
help: 'Current number of valid Novavps connections loaded.',
@@ -113,13 +107,14 @@ setInterval(() => {
async function syncPanelWithPing(
panel: PanelConfig,
valid: NovavpsConnection[],
state: AppState,
stateKey: "xray1" | "xray2"
state: AppState
): Promise<void> {
if (valid.length === 0) return;
const namePrefix = getConnectionNamePrefix();
try {
state.currentStep = stateKey === "xray1" ? "pinging" : "xray2_pinging";
state.currentStep = "pinging";
await saveState(state);
const pingResults: Array<{
@@ -128,7 +123,7 @@ async function syncPanelWithPing(
}> = [];
for (let i = 0; i < valid.length; i++) {
const conn = valid[i];
const tag = `novavps${i + 1}`;
const tag = `${namePrefix}${i + 1}`;
const port = parseInt(conn.port) || 443;
const latency = await tcpPing(conn.address, port);
pingResults.push({
@@ -136,8 +131,8 @@ async function syncPanelWithPing(
latency, error: latency === null ? "Таймаут или ошибка" : null,
});
}
state[stateKey].pingResults = pingResults;
state[stateKey].allPingsOk = pingResults.every((p) => p.latency !== null);
state.xray.pingResults = pingResults;
state.xray.allPingsOk = pingResults.every((p) => p.latency !== null);
let bestPingIndex = -1;
let bestLatency = Infinity;
@@ -149,24 +144,24 @@ async function syncPanelWithPing(
});
let syncConnections = [...valid];
let syncedTags = valid.map((_, idx) => `novavps${idx + 1}`);
let syncedTags = valid.map((_, idx) => `${namePrefix}${idx + 1}`);
if (bestPingIndex >= 0) {
syncConnections = [valid[bestPingIndex], ...valid];
syncedTags = ["novavps0", ...syncedTags];
syncedTags = [`${namePrefix}0`, ...syncedTags];
}
state.currentStep = stateKey === "xray1" ? "xray1_syncing" : "xray2_syncing";
state.currentStep = "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;
state.xray.synced = true;
state.xray.outboundsCount = syncedTags.length;
state.xray.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;
state.xray.error = errMsg;
panelStatus.set({ label: panel.label }, 0);
}
await saveState(state);
@@ -186,23 +181,23 @@ async function runParser(): Promise<void> {
const existingNovavpsPath = path.resolve(__dirname, "..", "data", "novavps-connections.json");
try {
const config = await loadConfig(process.env.NOVAVPS_URL || "");
const novavpsUrl = config.novavpsUrl;
const config = await loadConfig(process.env.subscription_url || "");
const subscriptionUrl = config.subscriptionUrl;
if (!novavpsUrl) {
if (!subscriptionUrl) {
state.status = "error";
state.currentStep = "novavps_url_missing";
state.novavps = { status: "error", count: 0, timestamp: new Date().toISOString(), error: "Novavps URL не настроен" };
state.currentStep = "subscription_url_missing";
state.novavps = { status: "error", count: 0, timestamp: new Date().toISOString(), error: "Ссылка подписки не настроена" };
await saveState(state);
parserRunsTotal.inc({ status: 'failed' });
return;
}
const panels = getPanels();
if (panels.length === 0) {
const panel = getPanel();
if (!panel) {
state.status = "error";
state.currentStep = "no_xray_panels";
state.novavps = { status: "error", count: 0, timestamp: new Date().toISOString(), error: "Не настроены панели Xray" };
state.currentStep = "no_xray_panel";
state.novavps = { status: "error", count: 0, timestamp: new Date().toISOString(), error: "Не настроена панель Xray" };
await saveState(state);
parserRunsTotal.inc({ status: 'failed' });
return;
@@ -213,7 +208,7 @@ async function runParser(): Promise<void> {
let novavpsConnections: Awaited<ReturnType<typeof parseNovavps>> = [];
try {
novavpsConnections = await parseNovavps(novavpsUrl);
novavpsConnections = await parseNovavps(subscriptionUrl);
if (novavpsConnections.length === 0) {
try {
@@ -249,49 +244,41 @@ async function runParser(): Promise<void> {
const valid = getValidConnections(novavpsConnections);
for (const panel of panels) {
state.currentStep = `${panel.label}_parsing`;
await saveState(state);
state.currentStep = "parsing";
await saveState(state);
try {
await parseXrayPanel(panel.url, panel.username, panel.password);
const panelStateKey = panel.label as "xray1" | "xray2";
state[panelStateKey] = {
status: "success",
outboundsCount: 0,
synced: false,
syncedOutbounds: [],
pingResults: panelStateKey === "xray1" ? [] : undefined,
allPingsOk: panelStateKey === "xray1" ? false : undefined,
timestamp: new Date().toISOString(),
error: null,
};
panelStatus.set({ label: panel.label }, 1);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error(`[${panel.label}] Ошибка парсинга:`, errMsg);
const errorState: import("./data-store").PanelStatus = {
status: "error",
outboundsCount: 0,
synced: false,
syncedOutbounds: [],
allPingsOk: false,
timestamp: new Date().toISOString(),
error: errMsg,
};
if (panel.label === "xray1") {
state.xray1 = errorState;
} else {
state.xray2 = errorState;
}
panelStatus.set({ label: panel.label }, 0);
}
await saveState(state);
try {
await parseXrayPanel(panel.url, panel.username, panel.password);
state.xray = {
status: "success",
outboundsCount: 0,
synced: false,
syncedOutbounds: [],
pingResults: [],
allPingsOk: false,
timestamp: new Date().toISOString(),
error: null,
};
panelStatus.set({ label: panel.label }, 1);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error(`[${panel.label}] Ошибка парсинга:`, errMsg);
state.xray = {
status: "error",
outboundsCount: 0,
synced: false,
syncedOutbounds: [],
pingResults: [],
allPingsOk: false,
timestamp: new Date().toISOString(),
error: errMsg,
};
panelStatus.set({ label: panel.label }, 0);
}
await saveState(state);
if (valid.length === 0) continue;
const panelStateKey = panel.label as "xray1" | "xray2";
await syncPanelWithPing(panel, valid, state, panelStateKey);
if (valid.length > 0) {
await syncPanelWithPing(panel, valid, state);
}
state.status = "completed";
@@ -316,119 +303,7 @@ async function runParser(): Promise<void> {
}
}
let isXray2Running = false;
async function runXray2Sync(): Promise<void> {
if (isXray2Running) return;
isXray2Running = true;
xray2SyncRunsTotal.inc({ status: 'started' });
panelStatus.set({ label: "xray2" }, 0);
const state = await loadState();
state.currentStep = "xray2_syncing";
state.xray2.status = "idle";
state.xray2.synced = false;
state.xray2.error = null;
await saveState(state);
const existingNovavpsPath = path.resolve(__dirname, "..", "data", "novavps-connections.json");
try {
let connections: NovavpsConnection[] = [];
try {
const raw = await fs.readFile(existingNovavpsPath, "utf-8");
connections = JSON.parse(raw);
} catch {
console.warn("[xray2] Нет сохранённых подключений Novavps");
state.xray2 = {
status: "error", outboundsCount: 0, synced: false, syncedOutbounds: [],
timestamp: new Date().toISOString(), error: "Нет сохранённых подключений Novavps",
};
state.currentStep = "done";
await saveState(state);
xray2SyncRunsTotal.inc({ status: 'failed' });
panelStatus.set({ label: "xray2" }, 0);
return;
}
const valid = getValidConnections(connections);
if (valid.length === 0) {
console.warn("[xray2] Нет валидных подключений Novavps");
state.xray2 = {
status: "error", outboundsCount: 0, synced: false, syncedOutbounds: [],
timestamp: new Date().toISOString(), error: "Нет валидных подключений",
};
state.currentStep = "done";
await saveState(state);
xray2SyncRunsTotal.inc({ status: 'failed' });
panelStatus.set({ label: "xray2" }, 0);
return;
}
const panel = getPanels().find((p) => p.label === "xray2");
if (!panel) {
console.warn("[xray2] Панель Xray2 не настроена в переменных окружения");
state.xray2 = {
status: "error", outboundsCount: 0, synced: false, syncedOutbounds: [],
timestamp: new Date().toISOString(), error: "Xray2 панель не настроена",
};
state.currentStep = "done";
await saveState(state);
xray2SyncRunsTotal.inc({ status: 'failed' });
panelStatus.set({ label: "xray2" }, 0);
return;
}
await syncNovavpsToXray(valid, panel.url, panel.username, panel.password, "xray2");
state.xray2 = {
status: "success",
outboundsCount: (await parseXrayPanel(panel.url, panel.username, panel.password)).length,
synced: true,
syncedOutbounds: valid.map((_, idx) => `novavps${idx + 1}`),
timestamp: new Date().toISOString(),
error: null,
};
state.currentStep = "done";
await saveState(state);
xray2SyncRunsTotal.inc({ status: 'success' });
panelStatus.set({ label: "xray2" }, 1);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error("[xray2] Критическая ошибка синхронизации:", errMsg);
state.xray2 = {
status: "error", outboundsCount: 0, synced: false, syncedOutbounds: [],
timestamp: new Date().toISOString(), error: errMsg,
};
state.currentStep = "done";
await saveState(state);
xray2SyncRunsTotal.inc({ status: 'failed' });
panelStatus.set({ label: "xray2" }, 0);
} finally {
await closeBrowser();
isXray2Running = false;
}
}
app.post("/api/sync-xray2", authMiddleware, async (_req: Request, res: Response) => {
if (isXray2Running) {
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 панель не настроена");
res.status(400).json({ error: "Xray2 панель не настроена. Проверьте переменные XRAY_URL2, XRAY_USERNAME2, XRAY_PASSWORD2" });
return;
}
runXray2Sync().catch((err) => console.error("[server] Xray2 sync error:", err));
res.json({ ok: true, message: "Синхронизация Xray2 запущена" });
});
app.post("/api/login", async (req: Request, res: Response) => {
const ip = req.ip || req.socket.remoteAddress || "unknown";
@@ -483,9 +358,10 @@ app.post("/api/logout", (_req: Request, res: Response) => {
app.get("/api/status", authMiddleware, async (_req: Request, res: Response) => {
const state = await loadState();
const panel = getPanel();
res.json({
...state,
configuredPanels: getPanels().map(p => p.label),
configuredPanel: panel ? panel.label : null,
});
});
@@ -499,28 +375,28 @@ app.post("/api/run", authMiddleware, async (_req: Request, res: Response) => {
});
app.get("/api/config", authMiddleware, async (_req: Request, res: Response) => {
const config = await loadConfig(process.env.NOVAVPS_URL || "");
const config = await loadConfig(process.env.subscription_url || "");
res.json(config);
});
app.put("/api/config", authMiddleware, async (req: Request, res: Response) => {
const { novavpsUrl } = req.body;
if (!novavpsUrl) {
res.status(400).json({ error: "novavpsUrl required" });
const { subscriptionUrl } = req.body;
if (!subscriptionUrl) {
res.status(400).json({ error: "subscriptionUrl 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" });
if (typeof subscriptionUrl !== "string" || subscriptionUrl.length < 10 || subscriptionUrl.length > 500) {
res.status(400).json({ error: "subscriptionUrl must be a string between 10 and 500 characters" });
return;
}
try {
new URL(novavpsUrl);
new URL(subscriptionUrl);
} catch {
res.status(400).json({ error: "novavpsUrl must be a valid URL" });
res.status(400).json({ error: "subscriptionUrl must be a valid URL" });
return;
}
const config = await loadConfig(process.env.NOVAVPS_URL || "");
config.novavpsUrl = novavpsUrl;
const config = await loadConfig(process.env.subscription_url || "");
config.subscriptionUrl = subscriptionUrl;
await saveConfig(config);
res.json({ ok: true });
});
@@ -561,7 +437,7 @@ app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
});
(async () => {
const config = await loadConfig(process.env.NOVAVPS_URL || "");
const config = await loadConfig(process.env.subscription_url || "");
const state = await loadState();
if (state.status === "running") {
@@ -571,16 +447,11 @@ app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.log("[server] Сброшено состояние 'running' после перезапуска");
}
if (state.xray2.status === "error" && !process.env.XRAY_URL2) {
state.xray2 = { status: "idle", outboundsCount: 0, synced: false, syncedOutbounds: [], timestamp: null, error: null };
await saveState(state);
console.log("[server] Сброшена ошибка Xray2 — панель не настроена");
}
const panel = getPanel();
app.listen(PORT, "0.0.0.0", () => {
console.log(`[server] Web interface running on http://0.0.0.0:${PORT}`);
console.log(`[server] NOVAVPS_URL: ${config.novavpsUrl || "(not set)"}`);
console.log(`[server] Panels: ${getPanels().map((p) => p.label).join(", ") || "(none)"}`);
console.log(`[server] subscription_url: ${config.subscriptionUrl || "(not set)"}`);
console.log(`[server] Panel: ${panel ? panel.label : "(none)"}`);
});
const PARSE_INTERVAL_MS = (parseInt(process.env.PARSE_INTERVAL || "1800", 10)) * 1000;

View File

@@ -4,6 +4,7 @@ import { readdirSync, unlinkSync } from "fs";
import type { Page } from "playwright";
import { newContext, getBrowser } from "./browser";
import type { NovavpsConnection } from "./novavps";
import { getConnectionNamePrefix } from "./config";
const DATA_DIR = path.resolve(__dirname, "..", "data");
const BACKUP_DIR = path.join(DATA_DIR, "xray-backups");
@@ -247,6 +248,7 @@ export async function syncNovavpsToXray(
return;
}
const namePrefix = getConnectionNamePrefix();
console.log(`[${panelLabel}] Синхронизирую ${connections.length} novavps подключений...`);
const context = await newContext();
@@ -314,11 +316,11 @@ export async function syncNovavpsToXray(
const outbounds = (config.outbounds as Record<string, unknown>[]) || [];
console.log(`[${panelLabel}] Всего outbound'ов в конфиге ДО: ${outbounds.length}`);
const existingNovavpsTags = outbounds.filter((o) => String(o.tag || "").startsWith("novavps"));
console.log(`[${panelLabel}] Из них с тегом novavps*: ${existingNovavpsTags.length}`);
const existingNovavpsTags = outbounds.filter((o) => String(o.tag || "").startsWith(namePrefix));
console.log(`[${panelLabel}] Из них с тегом ${namePrefix}*: ${existingNovavpsTags.length}`);
connections.forEach((conn, idx) => {
const tag = `novavps${startIndex + idx}`;
const tag = `${namePrefix}${startIndex + idx}`;
const newOutbound = buildVlessOutbound(conn, tag);
const existingIdx = outbounds.findIndex((o) => o.tag === tag);
@@ -333,8 +335,8 @@ export async function syncNovavpsToXray(
config.outbounds = outbounds;
const modifiedJson = JSON.stringify(config, null, 2);
const novavpsCountAfter = (outbounds.filter((o) => String(o.tag || "").startsWith("novavps"))).length;
console.log(`[${panelLabel}] После модификации: всего outbound'ов ${outbounds.length}, novavps*: ${novavpsCountAfter}`);
const novavpsCountAfter = (outbounds.filter((o) => String(o.tag || "").startsWith(namePrefix))).length;
console.log(`[${panelLabel}] После модификации: всего outbound'ов ${outbounds.length}, ${namePrefix}*: ${novavpsCountAfter}`);
await page.evaluate((json) => {
const cm = document.querySelector(".CodeMirror") as unknown as { CodeMirror?: { setValue: (v: string) => void } };
@@ -387,8 +389,8 @@ export async function syncNovavpsToXray(
try {
const verifyConfig = JSON.parse(verifyJson);
const verifyOutbounds = (verifyConfig.outbounds as Record<string, unknown>[]) || [];
const verifyNovavps = verifyOutbounds.filter((o) => String(o.tag || "").startsWith("novavps"));
console.log(`[${panelLabel}] После сохранения: outbound'ов ${verifyOutbounds.length}, novavps*: ${verifyNovavps.length}`);
const verifyNovavps = verifyOutbounds.filter((o) => String(o.tag || "").startsWith(namePrefix));
console.log(`[${panelLabel}] После сохранения: outbound'ов ${verifyOutbounds.length}, ${namePrefix}*: ${verifyNovavps.length}`);
verifyNovavps.forEach((o) => {
const settings = (o as Record<string, unknown>).settings as Record<string, unknown> | undefined;
const vnext = settings?.vnext as Record<string, unknown>[] | undefined;