This commit is contained in:
2026-05-18 08:49:45 +05:00
commit c4e8344e50
12 changed files with 1134 additions and 0 deletions

26
src/browser.ts Normal file
View File

@@ -0,0 +1,26 @@
import { chromium, Browser, BrowserContext } from "playwright";
let browser: Browser | null = null;
export async function getBrowser(): Promise<Browser> {
if (!browser) {
browser = await chromium.launch({ headless: true });
const exitHandler = () => browser?.close().catch(() => {});
process.on("exit", exitHandler);
process.on("SIGINT", exitHandler);
process.on("SIGTERM", exitHandler);
}
return browser;
}
export async function newContext(): Promise<BrowserContext> {
const b = await getBrowser();
return b.newContext();
}
export async function closeBrowser(): Promise<void> {
if (browser) {
await browser.close();
browser = null;
}
}

94
src/index.ts Normal file
View File

@@ -0,0 +1,94 @@
import dotenv from "dotenv";
import { parseNovavps, getValidConnections } from "./novavps";
import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel";
import { closeBrowser } from "./browser";
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;
if (!novavpsUrl) {
console.error("NOVAVPS_URL not set in .env");
process.exit(1);
}
const panels = getPanels();
if (panels.length === 0) {
console.error("Нет настроенных Xray панелей (XRAY_URL или XRAY_URL2)");
process.exit(1);
}
console.log("=== VPN Connection Parser ===\n");
let novavpsConnections: Awaited<ReturnType<typeof parseNovavps>> = [];
try {
novavpsConnections = await parseNovavps(novavpsUrl);
console.log(`[result] novavps: ${novavpsConnections.length} connections`);
} catch (err) {
console.error("[novavps] Error:", err instanceof Error ? err.message : String(err));
}
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();
}
await closeBrowser();
console.log("Done.");
}
main().catch((err) => {
console.error("Fatal error:", err);
closeBrowser().catch(() => {});
process.exit(1);
});

181
src/novavps.ts Normal file
View File

@@ -0,0 +1,181 @@
import path from "path";
import fs from "fs/promises";
import { newContext } from "./browser";
const DATA_DIR = path.resolve(__dirname, "..", "data");
const OUTPUT_FILE = path.join(DATA_DIR, "novavps-connections.json");
export interface NovavpsConnection {
name: string;
protocol: string;
address: string;
port: string;
uuid?: string;
security?: string;
flow?: string;
encryption?: string;
sni?: string;
fingerprint?: string;
publicKey?: string;
shortId?: string;
networkType?: string;
serviceName?: string;
rawLink: string;
}
export function getValidConnections(connections: NovavpsConnection[]): NovavpsConnection[] {
return connections.filter((c) => {
const addr = c.address.toLowerCase();
if (addr === "auto" || addr === "" || addr === "0.0.0.0") return false;
return true;
});
}
function parseProxyLink(link: string): NovavpsConnection {
const base: NovavpsConnection = {
name: "",
protocol: "",
address: "",
port: "",
rawLink: link,
};
try {
if (link.startsWith("vmess://")) {
base.protocol = "vmess";
const b64 = link.replace("vmess://", "").split("#")[0];
const config = JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
base.name = config.ps || "";
base.address = config.add || "";
base.port = String(config.port || "");
base.uuid = config.id || "";
base.security = config.security || "";
base.encryption = config.scy || "";
} else if (link.startsWith("vless://")) {
base.protocol = "vless";
const u = new URL(link);
base.name = decodeURIComponent(u.hash.replace("#", ""));
base.address = u.hostname;
base.port = u.port;
base.uuid = u.username;
base.security = u.searchParams.get("security") || "";
base.flow = u.searchParams.get("flow") || "";
base.encryption = u.searchParams.get("encryption") || "";
base.sni = u.searchParams.get("sni") || "";
base.fingerprint = u.searchParams.get("fp") || "";
base.publicKey = u.searchParams.get("pbk") || "";
base.shortId = u.searchParams.get("sid") || "";
base.networkType = u.searchParams.get("type") || "tcp";
base.serviceName = u.searchParams.get("serviceName") || "";
} else if (link.startsWith("trojan://")) {
base.protocol = "trojan";
const u = new URL(link);
base.name = decodeURIComponent(u.hash.replace("#", ""));
base.address = u.hostname;
base.port = u.port;
base.uuid = u.username;
base.security = u.searchParams.get("security") || "";
base.sni = u.searchParams.get("sni") || "";
} else if (link.startsWith("ss://")) {
base.protocol = "ss";
const hashSplit = link.split("#");
const withoutHash = hashSplit[0];
base.name = hashSplit.length > 1 ? decodeURIComponent(hashSplit[1]) : "";
const atIdx = withoutHash.lastIndexOf("@");
if (atIdx > 0) {
const methodPassB64 = withoutHash.replace("ss://", "").slice(0, atIdx - "ss://".length);
const hostPart = withoutHash.slice(atIdx + 1);
const colonIdx = hostPart.lastIndexOf(":");
if (colonIdx > 0) {
base.address = hostPart.slice(0, colonIdx);
base.port = hostPart.slice(colonIdx + 1).split("#")[0];
}
try {
const decoded = Buffer.from(methodPassB64, "base64").toString("utf-8");
const [method, password] = decoded.split(":");
base.encryption = method || "";
base.uuid = password || "";
} catch { /* ignore */ }
}
} else if (link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
base.protocol = "hysteria2";
const u = new URL(link);
base.name = decodeURIComponent(u.hash.replace("#", ""));
base.address = u.hostname;
base.port = u.port;
base.uuid = u.username;
base.sni = u.searchParams.get("sni") || "";
} else if (link.startsWith("tuic://")) {
base.protocol = "tuic";
const u = new URL(link);
base.name = decodeURIComponent(u.hash.replace("#", ""));
base.address = u.hostname;
base.port = u.port;
base.uuid = u.username;
} else if (link.startsWith("wireguard://") || link.startsWith("wg://")) {
base.protocol = "wireguard";
base.name = link;
} else if (link.startsWith("http")) {
base.protocol = "subscription";
base.name = "Subscription link";
base.address = link;
} else {
base.protocol = "unknown";
base.name = link;
}
} catch (err) {
base.name = `parse_error: ${err instanceof Error ? err.message : err}`;
}
return base;
}
async function save(data: NovavpsConnection[]): Promise<void> {
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(OUTPUT_FILE, JSON.stringify(data, null, 2), "utf-8");
console.log(`[novavps] Saved ${data.length} connections to ${OUTPUT_FILE}`);
}
export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
console.log(`[novavps] Opening page: ${url}`);
const context = await newContext();
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(3000);
const linksJson = await page.evaluate(() => {
let result: string | null = null;
const scripts = document.querySelectorAll("script");
scripts.forEach((script) => {
if (result) return;
const text = script.textContent || "";
if (!text.includes("panelData")) return;
const match = text.match(/JSON\.parse\(atob\('([^']+)'\)\)/);
if (!match) return;
try {
const data = JSON.parse(atob(match[1]));
result = JSON.stringify(data.response.links);
} catch { /* skip */ }
});
return result;
});
if (!linksJson) {
console.log("[novavps] Could not extract links from page");
return [];
}
const links: string[] = JSON.parse(linksJson);
console.log(`[novavps] Found ${links.length} links`);
const connections = links.map(parseProxyLink);
await save(connections);
return connections;
} finally {
await page.close();
await context.close();
}
}

412
src/xray-panel.ts Normal file
View File

@@ -0,0 +1,412 @@
import path from "path";
import fs from "fs/promises";
import { readdirSync, unlinkSync } from "fs";
import type { Page } from "playwright";
import { newContext, getBrowser } from "./browser";
import type { NovavpsConnection } from "./novavps";
const DATA_DIR = path.resolve(__dirname, "..", "data");
const BACKUP_DIR = path.join(DATA_DIR, "xray-backups");
const OUTBOUNDS_FILE = path.join(DATA_DIR, "xray-outbounds.json");
const MAX_BACKUPS = 45;
export interface XrayOutbound {
remark: string;
protocol: string;
address: string;
port: string;
details: Record<string, string>;
}
async function saveOutboundsFile(data: XrayOutbound[]): Promise<void> {
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(OUTBOUNDS_FILE, JSON.stringify(data, null, 2), "utf-8");
console.log(`[xray] Сохранено ${data.length} outbounds в ${OUTBOUNDS_FILE}`);
}
async function backupConfig(configJson: string): Promise<string> {
await fs.mkdir(BACKUP_DIR, { recursive: true });
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const filename = `config-${ts}.json`;
const filepath = path.join(BACKUP_DIR, filename);
await fs.writeFile(filepath, configJson, "utf-8");
console.log(`[xray] Бэкап сохранён: ${filename}`);
// Ротация — оставляем только MAX_BACKUPS последних
try {
const files = readdirSync(BACKUP_DIR)
.filter((f) => f.startsWith("config-") && f.endsWith(".json"))
.sort()
.reverse();
if (files.length > MAX_BACKUPS) {
for (const old of files.slice(MAX_BACKUPS)) {
unlinkSync(path.join(BACKUP_DIR, old));
}
console.log(`[xray] Ротация: удалено ${files.length - MAX_BACKUPS} старых бэкапов`);
}
} catch { /* ignore rotation errors */ }
return filepath;
}
function buildVlessOutbound(conn: NovavpsConnection, tag: string): Record<string, unknown> {
const outbound: Record<string, unknown> = {
tag,
protocol: "vless",
settings: {
vnext: [
{
address: conn.address,
port: parseInt(conn.port) || 443,
users: [
{
id: conn.uuid || "",
encryption: conn.encryption || "none",
flow: conn.flow || "",
},
],
},
],
},
streamSettings: {
network: conn.networkType || "tcp",
security: conn.security || "none",
},
};
if (conn.security === "reality") {
(outbound.streamSettings as Record<string, unknown>).realitySettings = {
serverName: conn.sni || conn.address,
fingerprint: conn.fingerprint || "chrome",
publicKey: conn.publicKey || "",
shortId: conn.shortId || "",
};
}
if (conn.networkType === "grpc" && conn.serviceName) {
(outbound.streamSettings as Record<string, unknown>).grpcSettings = {
serviceName: conn.serviceName,
};
}
return outbound;
}
async function loginAndNavigate(page: Page, url: string, username: string, password: string): Promise<void> {
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(2000);
const loginInput = await page.$('input[type="text"], input[name="username"], input[name="login"]');
const passInput = await page.$('input[type="password"]');
if (loginInput && passInput) {
await loginInput.fill(username);
await passInput.fill(password);
const submitBtn = await page.$('button[type="submit"], input[type="submit"]');
if (submitBtn) await submitBtn.click();
else await page.keyboard.press("Enter");
await page.waitForTimeout(3000);
await page.waitForLoadState("networkidle");
}
}
export async function parseXrayPanel(
url: string,
username: string,
password: string
): Promise<XrayOutbound[]> {
console.log(`[xray] Открываю панель: ${url}`);
const context = await newContext();
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(2000);
const loginInput = await page.$('input[type="text"], input[name="username"], input[name="login"]');
const passInput = await page.$('input[type="password"]');
if (loginInput && passInput) {
console.log("[xray] Логинюсь...");
await loginInput.fill(username);
await passInput.fill(password);
const submitBtn = await page.$('button[type="submit"], input[type="submit"]');
if (submitBtn) await submitBtn.click();
else await page.keyboard.press("Enter");
await page.waitForTimeout(3000);
await page.waitForLoadState("networkidle");
}
const baseUrl = new URL(url).origin;
const xrayUrl = `${baseUrl}/panel/xray`;
await page.goto(xrayUrl, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(3000);
// Кликаем на вкладку "Исходящие подключения"
await page.evaluate(() => {
let found = false;
document.querySelectorAll("button, a, span, div, li").forEach((el) => {
if (found) return;
const text = (el.textContent || "").trim();
if (text === "Исходящие подключения" || text === "Outbounds") {
(el as HTMLElement).click();
found = true;
}
});
});
await page.waitForTimeout(2000);
await page.waitForLoadState("networkidle");
const outbounds = await page.evaluate(() => {
const rows: Array<{
remark: string;
protocol: string;
address: string;
port: string;
details: Record<string, string>;
}> = [];
const tables = document.querySelectorAll("table");
tables.forEach((table) => {
const trs = table.querySelectorAll("tbody tr, tr");
trs.forEach((tr) => {
const tds = tr.querySelectorAll("td");
if (tds.length < 2) return;
const cells = Array.from(tds).map((td) => td.textContent?.trim() ?? "");
const text = cells.join(" ").toLowerCase();
const protocols = ["vless", "vmess", "shadowsocks", "trojan", "socks", "http", "wireguard"];
if (!protocols.some((p) => text.includes(p))) return;
const row = {
remark: cells[1] || cells[0] || "",
protocol: "",
address: "",
port: "",
details: {} as Record<string, string>,
};
cells.forEach((cell, idx) => {
const lower = cell.toLowerCase();
if (protocols.some((p) => lower.includes(p))) {
row.protocol = cell;
} else if (idx === 3 && cell.includes(":")) {
const [h, ...rest] = cell.split(":");
row.address = h;
row.port = rest.join(":");
} else if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(lower) || /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}/.test(lower)) {
if (!row.address) row.address = cell;
} else if (/^\d+$/.test(cell) && parseInt(cell) > 0 && parseInt(cell) < 65536) {
if (!row.port) row.port = cell;
}
if (idx > 0) row.details[`col${idx}`] = cell;
});
if (!row.protocol) {
for (const p of protocols) {
if (text.includes(p)) {
row.protocol = p.toUpperCase();
break;
}
}
}
rows.push(row);
});
});
return rows;
});
await saveOutboundsFile(outbounds);
return outbounds;
} finally {
await page.close();
await context.close();
}
}
export async function syncNovavpsToXray(
connections: NovavpsConnection[],
url: string,
username: string,
password: string,
panelLabel = "xray"
): Promise<void> {
if (connections.length === 0) {
console.log(`[${panelLabel}] Нет подключений для синхронизации`);
return;
}
console.log(`[${panelLabel}] Синхронизирую ${connections.length} novavps подключений...`);
const context = await newContext();
const page = await context.newPage();
try {
await loginAndNavigate(page, url, username, password);
const baseUrl = new URL(url).origin;
// Переход на страницу настроек Xray
await page.goto(`${baseUrl}/panel/xray`, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(2000);
// Клик на вкладку "Расширенный шаблон"
const templateClicked = await page.evaluate(() => {
let found = false;
document.querySelectorAll("button, a, span, div, li").forEach((el) => {
if (found) return;
const text = (el.textContent || "").trim();
if (text === "Расширенный шаблон" || text === "Extended Template") {
(el as HTMLElement).click();
found = true;
}
});
return found;
});
if (!templateClicked) {
console.log(`[${panelLabel}] Вкладка 'Расширенный шаблон' не найдена`);
return;
}
await page.waitForTimeout(2000);
await page.waitForLoadState("networkidle");
// Читаем JSON через CodeMirror API
const configJson = await page.evaluate(() => {
const cm = document.querySelector(".CodeMirror") as unknown as { CodeMirror?: { getValue: () => string } };
if (cm?.CodeMirror) return cm.CodeMirror.getValue();
const textarea = document.querySelector("textarea");
if (textarea && textarea.value.length > 100) return textarea.value;
let foundVal = "";
document.querySelectorAll("textarea").forEach((ta) => {
if (!foundVal && ta.value && ta.value.length > 100) foundVal = ta.value;
});
return foundVal;
});
if (!configJson) {
console.log(`[${panelLabel}] Не удалось прочитать JSON конфиг из редактора`);
return;
}
await backupConfig(configJson);
let config: Record<string, unknown>;
try {
config = JSON.parse(configJson);
} catch (e) {
console.log(`[${panelLabel}] Ошибка парсинга JSON: ${e}`);
return;
}
const outbounds = (config.outbounds as Record<string, unknown>[]) || [];
connections.forEach((conn, idx) => {
const tag = `novavps${idx + 1}`;
const newOutbound = buildVlessOutbound(conn, tag);
const existingIdx = outbounds.findIndex((o) => o.tag === tag);
if (existingIdx >= 0) {
outbounds[existingIdx] = newOutbound;
console.log(`[${panelLabel}] Outbound ${tag} обновлён`);
} else {
outbounds.push(newOutbound);
console.log(`[${panelLabel}] Outbound ${tag} добавлен`);
}
});
config.outbounds = outbounds;
const modifiedJson = JSON.stringify(config, null, 2);
await page.evaluate((json) => {
const cm = document.querySelector(".CodeMirror") as unknown as { CodeMirror?: { setValue: (v: string) => void } };
if (cm?.CodeMirror) {
cm.CodeMirror.setValue(json);
return;
}
document.querySelectorAll("textarea").forEach((ta) => {
if (ta.value && ta.value.length > 100) {
ta.value = json;
ta.dispatchEvent(new Event("input", { bubbles: true }));
ta.dispatchEvent(new Event("change", { bubbles: true }));
}
});
}, modifiedJson);
await page.waitForTimeout(500);
const saved = await page.evaluate(() => {
let clicked = false;
document.querySelectorAll("button").forEach((btn) => {
if (clicked) return;
const text = (btn.textContent || "").trim();
if (text === "Сохранить" || text === "Save") {
btn.click();
clicked = true;
}
});
return clicked;
});
if (saved) {
console.log(`[${panelLabel}] Конфиг сохранён`);
await page.waitForTimeout(2000);
await page.waitForLoadState("networkidle");
} else {
console.log(`[${panelLabel}] Кнопка 'Сохранить' не найдена`);
}
await page.goto(`${baseUrl}/panel/`, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(3000);
const dashboardText = await page.evaluate(() => document.body.innerText);
console.log(`[${panelLabel}] Текст дашборда (первые 500 символов):`);
console.log(dashboardText.slice(0, 500));
const restarted = await page.evaluate(() => {
const texts = ["Перезапустить", "Restart", "Остановить", "Stop", "Запустить", "Start"];
const allEls = document.querySelectorAll("button, a, span, div, li");
let result = "";
allEls.forEach((el) => {
if (result) return;
const t = (el.textContent || "").trim();
for (const target of texts) {
if (t === target || t.includes(target)) {
(el as HTMLElement).click();
result = `clicked: ${target}`;
return;
}
}
});
return result || "not_found";
});
if (restarted.startsWith("clicked")) {
console.log(`[${panelLabel}] Нажата кнопка: ${restarted}`);
await page.waitForTimeout(3000);
} else {
console.log(`[${panelLabel}] Кнопка не найдена (${restarted})`);
}
} finally {
await page.close();
await context.close();
}
}