xray
This commit is contained in:
124
src/novavps.ts
124
src/novavps.ts
@@ -1,10 +1,39 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
|
import https from "https";
|
||||||
|
import http from "http";
|
||||||
import { newContext } from "./browser";
|
import { newContext } from "./browser";
|
||||||
|
|
||||||
const DATA_DIR = path.resolve(__dirname, "..", "data");
|
const DATA_DIR = path.resolve(__dirname, "..", "data");
|
||||||
const OUTPUT_FILE = path.join(DATA_DIR, "novavps-connections.json");
|
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 {
|
export interface NovavpsConnection {
|
||||||
name: string;
|
name: string;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
@@ -136,8 +165,12 @@ async function save(data: NovavpsConnection[]): Promise<void> {
|
|||||||
console.log(`[novavps] Saved ${data.length} connections to ${OUTPUT_FILE}`);
|
console.log(`[novavps] Saved ${data.length} connections to ${OUTPUT_FILE}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
|
function isDirectSubscriptionUrl(url: string): boolean {
|
||||||
console.log(`[novavps] Opening page: ${url}`);
|
return !url.includes("novavps.app");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseNovavpsPage(url: string): Promise<NovavpsConnection[]> {
|
||||||
|
console.log(`[novavps] Opening novavps page: ${url}`);
|
||||||
|
|
||||||
const context = await newContext();
|
const context = await newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
@@ -171,11 +204,92 @@ export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
|
|||||||
const links: string[] = JSON.parse(linksJson);
|
const links: string[] = JSON.parse(linksJson);
|
||||||
console.log(`[novavps] Found ${links.length} links`);
|
console.log(`[novavps] Found ${links.length} links`);
|
||||||
|
|
||||||
const connections = links.map(parseProxyLink);
|
return links.map(parseProxyLink);
|
||||||
await save(connections);
|
|
||||||
return connections;
|
|
||||||
} finally {
|
} finally {
|
||||||
await page.close();
|
await page.close();
|
||||||
await context.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 promClient from 'prom-client';
|
||||||
import { parseNovavps, getValidConnections, NovavpsConnection } from "./novavps";
|
import { parseNovavps, getValidConnections, NovavpsConnection } from "./novavps";
|
||||||
import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel";
|
import { parseXrayPanel, syncNovavpsToXray, healthCheckXrayConnection } from "./xray-panel";
|
||||||
import { closeBrowser } from "./browser";
|
import { closeBrowser } from "./browser";
|
||||||
import { tcpPing } from "./ping";
|
import { tcpPing } from "./ping";
|
||||||
import {
|
import {
|
||||||
@@ -167,10 +167,33 @@ async function syncPanelWithPing(
|
|||||||
await saveState(state);
|
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;
|
let isRunning = false;
|
||||||
|
|
||||||
async function runParser(): Promise<void> {
|
async function runParser(): Promise<void> {
|
||||||
if (isRunning) return;
|
if (isRunning || isHealthChecking) return;
|
||||||
isRunning = true;
|
isRunning = true;
|
||||||
|
|
||||||
novavpsConnectionsCount.set(0);
|
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 PARSE_INTERVAL_MS = (parseInt(process.env.PARSE_INTERVAL || "1800", 10)) * 1000;
|
||||||
|
const HC_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log(`[server] Автозапуск парсера через 5с...`);
|
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}с)...`);
|
console.log(`[server] Плановый запуск парсера (интервал ${PARSE_INTERVAL_MS / 1000}с)...`);
|
||||||
runParser();
|
runParser();
|
||||||
}, PARSE_INTERVAL_MS);
|
}, 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 { newContext, getBrowser } from "./browser";
|
||||||
import type { NovavpsConnection } from "./novavps";
|
import type { NovavpsConnection } from "./novavps";
|
||||||
import { getConnectionNamePrefix } from "./config";
|
import { getConnectionNamePrefix } from "./config";
|
||||||
|
import { tcpPing } from "./ping";
|
||||||
|
|
||||||
const DATA_DIR = path.resolve(__dirname, "..", "data");
|
const DATA_DIR = path.resolve(__dirname, "..", "data");
|
||||||
const BACKUP_DIR = path.join(DATA_DIR, "xray-backups");
|
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));
|
const existingNovavpsTags = outbounds.filter((o) => String(o.tag || "").startsWith(namePrefix));
|
||||||
console.log(`[${panelLabel}] Из них с тегом ${namePrefix}*: ${existingNovavpsTags.length}`);
|
console.log(`[${panelLabel}] Из них с тегом ${namePrefix}*: ${existingNovavpsTags.length}`);
|
||||||
|
|
||||||
|
const syncedTags = new Set<string>();
|
||||||
|
|
||||||
connections.forEach((conn, idx) => {
|
connections.forEach((conn, idx) => {
|
||||||
const tag = `${namePrefix}${startIndex + idx}`;
|
const tag = `${namePrefix}${startIndex + idx}`;
|
||||||
|
syncedTags.add(tag);
|
||||||
const newOutbound = buildVlessOutbound(conn, tag);
|
const newOutbound = buildVlessOutbound(conn, tag);
|
||||||
|
|
||||||
const existingIdx = outbounds.findIndex((o) => o.tag === 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;
|
config.outbounds = outbounds;
|
||||||
const modifiedJson = JSON.stringify(config, null, 2);
|
const modifiedJson = JSON.stringify(config, null, 2);
|
||||||
const novavpsCountAfter = (outbounds.filter((o) => String(o.tag || "").startsWith(namePrefix))).length;
|
const novavpsCountAfter = (outbounds.filter((o) => String(o.tag || "").startsWith(namePrefix))).length;
|
||||||
@@ -441,3 +454,216 @@ export async function syncNovavpsToXray(
|
|||||||
await context.close();
|
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