309 lines
8.8 KiB
TypeScript
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)"}`);
|
|
});
|
|
})();
|