Files
parse_link_vpn/src/server.ts
2026-05-18 19:40:48 +05:00

309 lines
8.8 KiB
TypeScript

import dotenv from "dotenv";
dotenv.config();
import express, { Request, Response, NextFunction } from "express";
import cookieParser from "cookie-parser";
import crypto from "crypto";
import path from "path";
import fs from "fs/promises";
import { parseNovavps, getValidConnections } from "./novavps";
import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel";
import { closeBrowser } from "./browser";
import {
loadConfig,
saveConfig,
loadState,
saveState,
resetState,
AppState,
} from "./data-store";
const app = express();
const PORT = parseInt(process.env.PORT || "3000", 10);
const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString("hex");
const SESSION_TTL = 60 * 60 * 1000;
const sessions = new Map<string, { createdAt: number; lastAccess: number }>();
app.use(express.json({ limit: "1mb" }));
app.use(cookieParser());
app.use(express.static(path.resolve(__dirname, "..", "public")));
interface PanelConfig {
url: string;
username: string;
password: string;
label: string;
}
function getPanels(): PanelConfig[] {
const panels: PanelConfig[] = [];
const url1 = process.env.XRAY_URL;
const user1 = process.env.XRAY_USERNAME;
const pass1 = process.env.XRAY_PASSWORD;
if (url1 && user1 && pass1) {
panels.push({ url: url1, username: user1, password: pass1, label: "xray1" });
}
const url2 = process.env.XRAY_URL2;
const user2 = process.env.XRAY_USERNAME2;
const pass2 = process.env.XRAY_PASSWORD2;
if (url2 && user2 && pass2) {
panels.push({ url: url2, username: user2, password: pass2, label: "xray2" });
}
return panels;
}
function validCredentials(username: string, password: string): boolean {
const panels = getPanels();
return panels.some(
(p) => p.username === username && p.password === password
);
}
function createSession(): string {
const token = crypto.randomBytes(32).toString("hex");
const now = Date.now();
sessions.set(token, { createdAt: now, lastAccess: now });
return token;
}
function getSession(token: string | undefined): { createdAt: number; lastAccess: number } | null {
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (Date.now() - session.lastAccess > SESSION_TTL) {
sessions.delete(token);
return null;
}
session.lastAccess = Date.now();
return session;
}
function authMiddleware(req: Request, res: Response, next: NextFunction) {
const token = req.cookies?.session;
if (!getSession(token)) {
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
}
function cleanupSessions() {
const now = Date.now();
for (const [token, session] of sessions.entries()) {
if (now - session.lastAccess > SESSION_TTL) {
sessions.delete(token);
}
}
}
setInterval(cleanupSessions, 5 * 60 * 1000);
let isRunning = false;
async function runParser(): Promise<void> {
if (isRunning) return;
isRunning = true;
const state = await resetState();
try {
const config = await loadConfig(process.env.NOVAVPS_URL || "");
const novavpsUrl = config.novavpsUrl;
if (!novavpsUrl) {
state.status = "error";
state.currentStep = "novavps_url_missing";
await saveState(state);
return;
}
const panels = getPanels();
if (panels.length === 0) {
state.status = "error";
state.currentStep = "no_xray_panels";
await saveState(state);
return;
}
state.currentStep = "novavps_fetching";
await saveState(state);
let novavpsConnections: Awaited<ReturnType<typeof parseNovavps>> = [];
try {
novavpsConnections = await parseNovavps(novavpsUrl);
state.novavps = {
status: "success",
count: novavpsConnections.length,
timestamp: new Date().toISOString(),
error: null,
};
} catch (err) {
state.novavps = {
status: "error",
count: 0,
timestamp: new Date().toISOString(),
error: err instanceof Error ? err.message : String(err),
};
}
await saveState(state);
const valid = getValidConnections(novavpsConnections);
for (const panel of panels) {
state.currentStep = `${panel.label}_parsing`;
await saveState(state);
try {
const xray = await parseXrayPanel(panel.url, panel.username, panel.password);
const panelStateKey = panel.label as "xray1" | "xray2";
state[panelStateKey] = {
status: "success",
outboundsCount: xray.length,
synced: false,
syncedOutbounds: [],
timestamp: new Date().toISOString(),
error: null,
};
} catch (err) {
const errorState = {
status: "error" as const,
outboundsCount: 0,
synced: false,
syncedOutbounds: [],
timestamp: new Date().toISOString(),
error: err instanceof Error ? err.message : String(err),
};
if (panel.label === "xray1") {
state.xray1 = errorState;
} else {
state.xray2 = errorState;
}
}
await saveState(state);
if (valid.length > 0) {
state.currentStep = `${panel.label}_syncing`;
await saveState(state);
try {
await syncNovavpsToXray(valid, panel.url, panel.username, panel.password, panel.label);
const syncedTags = valid.map((_, idx) => `novavps${idx + 1}`);
const panelStateKey = panel.label as "xray1" | "xray2";
state[panelStateKey].synced = true;
state[panelStateKey].syncedOutbounds = syncedTags;
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (panel.label === "xray1") {
state.xray1.error = errMsg;
} else {
state.xray2.error = errMsg;
}
}
await saveState(state);
}
}
state.status = "completed";
state.currentStep = "done";
await saveState(state);
} catch (err) {
state.status = "error";
state.currentStep = "fatal";
if (state.novavps.status === "idle") {
state.novavps = { status: "error", count: 0, timestamp: new Date().toISOString(), error: err instanceof Error ? err.message : String(err) };
}
await saveState(state);
} finally {
await closeBrowser();
isRunning = false;
}
}
app.post("/api/login", async (req: Request, res: Response) => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: "username and password required" });
return;
}
if (!validCredentials(username, password)) {
res.status(401).json({ error: "Invalid credentials" });
return;
}
const token = createSession();
res.cookie("session", token, {
httpOnly: true,
sameSite: "strict",
maxAge: SESSION_TTL,
path: "/",
});
res.json({ ok: true });
});
app.post("/api/logout", (_req: Request, res: Response) => {
res.clearCookie("session");
res.json({ ok: true });
});
app.get("/api/status", authMiddleware, async (_req: Request, res: Response) => {
const state = await loadState();
res.json(state);
});
app.post("/api/run", authMiddleware, async (_req: Request, res: Response) => {
if (isRunning) {
res.status(409).json({ error: "Parser already running" });
return;
}
runParser().catch((err) => console.error("[server] Parser error:", err));
res.json({ ok: true, message: "Parser started" });
});
app.get("/api/config", authMiddleware, async (_req: Request, res: Response) => {
const config = await loadConfig(process.env.NOVAVPS_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" });
return;
}
const config = await loadConfig(process.env.NOVAVPS_URL || "");
config.novavpsUrl = novavpsUrl;
await saveConfig(config);
res.json({ ok: true });
});
app.get("/api/data/novavps", authMiddleware, async (_req: Request, res: Response) => {
const filePath = path.resolve(__dirname, "..", "data", "novavps-connections.json");
try {
const raw = await fs.readFile(filePath, "utf-8");
res.json(JSON.parse(raw));
} catch {
res.json([]);
}
});
app.get("/api/data/xray", authMiddleware, async (_req: Request, res: Response) => {
const filePath = path.resolve(__dirname, "..", "data", "xray-outbounds.json");
try {
const raw = await fs.readFile(filePath, "utf-8");
res.json(JSON.parse(raw));
} catch {
res.json([]);
}
});
(async () => {
const config = await loadConfig(process.env.NOVAVPS_URL || "");
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)"}`);
});
})();