xray
This commit is contained in:
124
src/novavps.ts
124
src/novavps.ts
@@ -1,10 +1,39 @@
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
import { newContext } from "./browser";
|
||||
|
||||
const DATA_DIR = path.resolve(__dirname, "..", "data");
|
||||
const OUTPUT_FILE = path.join(DATA_DIR, "novavps-connections.json");
|
||||
|
||||
interface HttpResult { status: number; body: string; contentType: string }
|
||||
|
||||
function httpGet(url: string): Promise<HttpResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
const req = mod.get(url, {
|
||||
headers: {
|
||||
"User-Agent": "v2rayN/1.0",
|
||||
"Accept": "*/*",
|
||||
},
|
||||
timeout: 30000,
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
const contentType = res.headers["content-type"] || "";
|
||||
const charsetMatch = contentType.match(/charset=([\w-]+)/i);
|
||||
const encoding = charsetMatch ? charsetMatch[1] : "utf-8";
|
||||
resolve({ status: res.statusCode || 0, body: body.toString(encoding as BufferEncoding), contentType });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); });
|
||||
});
|
||||
}
|
||||
|
||||
export interface NovavpsConnection {
|
||||
name: string;
|
||||
protocol: string;
|
||||
@@ -136,8 +165,12 @@ async function save(data: NovavpsConnection[]): Promise<void> {
|
||||
console.log(`[novavps] Saved ${data.length} connections to ${OUTPUT_FILE}`);
|
||||
}
|
||||
|
||||
export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
|
||||
console.log(`[novavps] Opening page: ${url}`);
|
||||
function isDirectSubscriptionUrl(url: string): boolean {
|
||||
return !url.includes("novavps.app");
|
||||
}
|
||||
|
||||
async function parseNovavpsPage(url: string): Promise<NovavpsConnection[]> {
|
||||
console.log(`[novavps] Opening novavps page: ${url}`);
|
||||
|
||||
const context = await newContext();
|
||||
const page = await context.newPage();
|
||||
@@ -171,11 +204,92 @@ export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
|
||||
const links: string[] = JSON.parse(linksJson);
|
||||
console.log(`[novavps] Found ${links.length} links`);
|
||||
|
||||
const connections = links.map(parseProxyLink);
|
||||
await save(connections);
|
||||
return connections;
|
||||
return links.map(parseProxyLink);
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function parseDirectSubscription(url: string): Promise<NovavpsConnection[]> {
|
||||
console.log(`[sub] Fetching subscription: ${url}`);
|
||||
|
||||
let result: HttpResult;
|
||||
try {
|
||||
result = await httpGet(url);
|
||||
} catch (err) {
|
||||
console.error(`[sub] HTTP error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
console.error(`[sub] HTTP ${result.status}: ${result.body.slice(0, 200)}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Reject HTML responses (error pages, etc.)
|
||||
if (result.contentType.includes("text/html") || /^\s*</.test(result.body.trim())) {
|
||||
console.error(`[sub] Got HTML response instead of subscription data (content-type: ${result.contentType})`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const body = result.body;
|
||||
if (!body || body.length < 10) {
|
||||
console.log("[sub] Empty or too short response");
|
||||
return [];
|
||||
}
|
||||
|
||||
// Try base64 decode first (standard V2Ray subscription format)
|
||||
let text: string;
|
||||
const b64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
const stripped = body.replace(/\s/g, "");
|
||||
if (stripped.length > 20 && b64Pattern.test(stripped)) {
|
||||
try {
|
||||
text = Buffer.from(stripped, "base64").toString("utf-8");
|
||||
console.log("[sub] Successfully decoded base64 subscription");
|
||||
} catch {
|
||||
text = body;
|
||||
}
|
||||
} else {
|
||||
text = body;
|
||||
}
|
||||
|
||||
// If decoded text looks like HTML, reject it
|
||||
if (/^\s*</.test(text.trim()) || text.includes("<html") || text.includes("<HTML")) {
|
||||
console.error("[sub] Decoded content is HTML, not a valid subscription");
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
|
||||
// Filter out HTML/error lines and only keep known proxy link prefixes
|
||||
const proxyLines = lines.filter((l) =>
|
||||
/^(vmess|vless|trojan|ss|hysteria2|hy2|tuic|wireguard|wg):\/\//.test(l)
|
||||
);
|
||||
|
||||
if (proxyLines.length === 0) {
|
||||
console.log(`[sub] No valid proxy links found (${lines.length} total lines, none matched known formats)`);
|
||||
if (lines.length > 0) {
|
||||
console.log(`[sub] First line preview: ${lines[0].slice(0, 200)}`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`[sub] Found ${proxyLines.length} proxy links`);
|
||||
return proxyLines.map(parseProxyLink);
|
||||
}
|
||||
|
||||
export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
|
||||
let connections: NovavpsConnection[];
|
||||
|
||||
if (isDirectSubscriptionUrl(url)) {
|
||||
connections = await parseDirectSubscription(url);
|
||||
} else {
|
||||
connections = await parseNovavpsPage(url);
|
||||
}
|
||||
|
||||
if (connections.length > 0) {
|
||||
await save(connections);
|
||||
}
|
||||
return connections;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import fs from "fs/promises";
|
||||
|
||||
import promClient from 'prom-client';
|
||||
import { parseNovavps, getValidConnections, NovavpsConnection } from "./novavps";
|
||||
import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel";
|
||||
import { parseXrayPanel, syncNovavpsToXray, healthCheckXrayConnection } from "./xray-panel";
|
||||
import { closeBrowser } from "./browser";
|
||||
import { tcpPing } from "./ping";
|
||||
import {
|
||||
@@ -167,10 +167,33 @@ async function syncPanelWithPing(
|
||||
await saveState(state);
|
||||
}
|
||||
|
||||
let isHealthChecking = false;
|
||||
|
||||
async function runHealthCheck(): Promise<void> {
|
||||
if (isHealthChecking || isRunning) return;
|
||||
isHealthChecking = true;
|
||||
|
||||
const panel = getPanel();
|
||||
if (!panel) {
|
||||
console.log("[hc] Нет панели Xray — пропускаю health check");
|
||||
isHealthChecking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await healthCheckXrayConnection(panel.url, panel.username, panel.password, panel.label);
|
||||
console.log(`[hc] Результат: ${result.message}`);
|
||||
} catch (err) {
|
||||
console.error("[hc] Ошибка:", err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
isHealthChecking = false;
|
||||
}
|
||||
}
|
||||
|
||||
let isRunning = false;
|
||||
|
||||
async function runParser(): Promise<void> {
|
||||
if (isRunning) return;
|
||||
if (isRunning || isHealthChecking) return;
|
||||
isRunning = true;
|
||||
|
||||
novavpsConnectionsCount.set(0);
|
||||
@@ -455,6 +478,7 @@ app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
});
|
||||
|
||||
const PARSE_INTERVAL_MS = (parseInt(process.env.PARSE_INTERVAL || "1800", 10)) * 1000;
|
||||
const HC_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`[server] Автозапуск парсера через 5с...`);
|
||||
@@ -465,4 +489,14 @@ app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
console.log(`[server] Плановый запуск парсера (интервал ${PARSE_INTERVAL_MS / 1000}с)...`);
|
||||
runParser();
|
||||
}, PARSE_INTERVAL_MS);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`[server] Первая проверка health check через 30с...`);
|
||||
runHealthCheck();
|
||||
}, 30000);
|
||||
|
||||
setInterval(() => {
|
||||
console.log(`[server] Плановая проверка health check (интервал ${HC_INTERVAL_MS / 1000}с)...`);
|
||||
runHealthCheck();
|
||||
}, HC_INTERVAL_MS);
|
||||
})();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Page } from "playwright";
|
||||
import { newContext, getBrowser } from "./browser";
|
||||
import type { NovavpsConnection } from "./novavps";
|
||||
import { getConnectionNamePrefix } from "./config";
|
||||
import { tcpPing } from "./ping";
|
||||
|
||||
const DATA_DIR = path.resolve(__dirname, "..", "data");
|
||||
const BACKUP_DIR = path.join(DATA_DIR, "xray-backups");
|
||||
@@ -319,8 +320,11 @@ export async function syncNovavpsToXray(
|
||||
const existingNovavpsTags = outbounds.filter((o) => String(o.tag || "").startsWith(namePrefix));
|
||||
console.log(`[${panelLabel}] Из них с тегом ${namePrefix}*: ${existingNovavpsTags.length}`);
|
||||
|
||||
const syncedTags = new Set<string>();
|
||||
|
||||
connections.forEach((conn, idx) => {
|
||||
const tag = `${namePrefix}${startIndex + idx}`;
|
||||
syncedTags.add(tag);
|
||||
const newOutbound = buildVlessOutbound(conn, tag);
|
||||
|
||||
const existingIdx = outbounds.findIndex((o) => o.tag === tag);
|
||||
@@ -333,6 +337,15 @@ export async function syncNovavpsToXray(
|
||||
}
|
||||
});
|
||||
|
||||
// Удаление устаревших outbound'ов (подписка уменьшилась)
|
||||
for (let i = outbounds.length - 1; i >= 0; i--) {
|
||||
const tag = String(outbounds[i].tag || "");
|
||||
if (tag.startsWith(namePrefix) && !syncedTags.has(tag)) {
|
||||
console.log(`[${panelLabel}] Удаляю устаревший ${tag}`);
|
||||
outbounds.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
config.outbounds = outbounds;
|
||||
const modifiedJson = JSON.stringify(config, null, 2);
|
||||
const novavpsCountAfter = (outbounds.filter((o) => String(o.tag || "").startsWith(namePrefix))).length;
|
||||
@@ -441,3 +454,216 @@ export async function syncNovavpsToXray(
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers for editor read/write ----------
|
||||
|
||||
async function readConfigFromEditor(page: Page): Promise<string | null> {
|
||||
return 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 || null;
|
||||
});
|
||||
}
|
||||
|
||||
async function writeConfigToEditor(page: Page, json: string): Promise<void> {
|
||||
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 }));
|
||||
}
|
||||
});
|
||||
}, json);
|
||||
}
|
||||
|
||||
async function clickSaveButton(page: Page): Promise<boolean> {
|
||||
return 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;
|
||||
});
|
||||
}
|
||||
|
||||
async function clickRestartButton(page: Page, baseUrl: string): Promise<void> {
|
||||
await page.goto(`${baseUrl}/panel/`, { waitUntil: "load", timeout: 60000 });
|
||||
await page.waitForTimeout(5000);
|
||||
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(`[xray] Нажата кнопка: ${restarted}`);
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
}
|
||||
|
||||
function getOutboundAddress(outbound: Record<string, unknown>): { address: string; port: number } | null {
|
||||
const settings = outbound.settings as Record<string, unknown> | undefined;
|
||||
const vnext = settings?.vnext as Record<string, unknown>[] | undefined;
|
||||
if (!vnext || vnext.length === 0) return null;
|
||||
return {
|
||||
address: String(vnext[0].address || ""),
|
||||
port: parseInt(String(vnext[0].port)) || 443,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- health check ----------
|
||||
|
||||
export async function healthCheckXrayConnection(
|
||||
url: string,
|
||||
username: string,
|
||||
password: string,
|
||||
panelLabel: string
|
||||
): Promise<{ changed: boolean; message: string }> {
|
||||
const namePrefix = getConnectionNamePrefix();
|
||||
const context = await newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await loginAndNavigate(page, url, username, password);
|
||||
const baseUrl = new URL(url).origin;
|
||||
|
||||
await page.goto(`${baseUrl}/panel/xray`, { waitUntil: "load", timeout: 60000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
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) {
|
||||
return { changed: false, message: "Вкладка 'Расширенный шаблон' не найдена" };
|
||||
}
|
||||
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
const configJson = await readConfigFromEditor(page);
|
||||
if (!configJson) {
|
||||
return { changed: false, message: "Не удалось прочитать конфиг" };
|
||||
}
|
||||
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(configJson);
|
||||
} catch {
|
||||
return { changed: false, message: "Ошибка парсинга JSON конфига" };
|
||||
}
|
||||
|
||||
const outbounds = (config.outbounds as Record<string, unknown>[]) || [];
|
||||
const ourOutbounds = outbounds
|
||||
.map((o, i) => ({ outbound: o, index: i, tag: String(o.tag || "") }))
|
||||
.filter((x) => x.tag.startsWith(namePrefix))
|
||||
.sort((a, b) => a.tag.localeCompare(b.tag));
|
||||
|
||||
if (ourOutbounds.length === 0) {
|
||||
return { changed: false, message: `Нет outbound'ов с тегом ${namePrefix}*` };
|
||||
}
|
||||
|
||||
const primaryTag = `${namePrefix}0`;
|
||||
const primaryEntry = ourOutbounds.find((x) => x.tag === primaryTag);
|
||||
if (!primaryEntry) {
|
||||
return { changed: false, message: `${primaryTag} не найден` };
|
||||
}
|
||||
|
||||
const primaryAddr = getOutboundAddress(primaryEntry.outbound);
|
||||
if (!primaryAddr || !primaryAddr.address) {
|
||||
return { changed: false, message: `${primaryTag} не содержит адреса` };
|
||||
}
|
||||
|
||||
console.log(`[hc] Пингую ${primaryTag} (${primaryAddr.address}:${primaryAddr.port})...`);
|
||||
const primaryLatency = await tcpPing(primaryAddr.address, primaryAddr.port);
|
||||
|
||||
if (primaryLatency !== null) {
|
||||
console.log(`[hc] ${primaryTag} отвечает (${primaryLatency}ms) — здоровье OK`);
|
||||
return { changed: false, message: `${primaryTag} отвечает (${primaryLatency}ms)` };
|
||||
}
|
||||
|
||||
console.log(`[hc] ${primaryTag} не отвечает — проверяю все ${namePrefix}*...`);
|
||||
|
||||
const results: Array<{ tag: string; index: number; latency: number | null; address: string; port: number; outbound: Record<string, unknown> }> = [];
|
||||
for (const entry of ourOutbounds) {
|
||||
const addr = getOutboundAddress(entry.outbound);
|
||||
if (!addr || !addr.address) continue;
|
||||
console.log(`[hc] Пингую ${entry.tag} (${addr.address}:${addr.port})...`);
|
||||
const lat = await tcpPing(addr.address, addr.port);
|
||||
results.push({ ...entry, ...addr, latency: lat, outbound: entry.outbound });
|
||||
}
|
||||
|
||||
const alive = results.filter((r) => r.latency !== null).sort((a, b) => (a.latency || Infinity) - (b.latency || Infinity));
|
||||
|
||||
if (alive.length === 0) {
|
||||
console.log(`[hc] Ни один ${namePrefix}* не отвечает`);
|
||||
return { changed: false, message: `Нет доступных ${namePrefix}* подключений` };
|
||||
}
|
||||
|
||||
const best = alive[0];
|
||||
console.log(`[hc] Лучший: ${best.tag} (${best.latency}ms) — делаю ${primaryTag}`);
|
||||
|
||||
// Remove the best outbound from its current position, make it primary at same index
|
||||
const bestOutbound = outbounds.splice(best.index, 1)[0];
|
||||
bestOutbound.tag = primaryTag;
|
||||
|
||||
// Reinsert primary at the original index of the old primary
|
||||
outbounds.splice(primaryEntry.index, 0, bestOutbound);
|
||||
|
||||
config.outbounds = outbounds;
|
||||
const modifiedJson = JSON.stringify(config, null, 2);
|
||||
|
||||
await backupConfig(configJson);
|
||||
await writeConfigToEditor(page, modifiedJson);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const saved = await clickSaveButton(page);
|
||||
if (saved) {
|
||||
console.log(`[hc] Конфиг сохранён`);
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
|
||||
await clickRestartButton(page, baseUrl);
|
||||
|
||||
return { changed: true, message: `Переназначил ${best.tag} → ${primaryTag} (${best.latency}ms)` };
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user