gui version

This commit is contained in:
Den Piligrim
2026-01-28 12:05:10 +03:00
parent f574931c25
commit ba6db6edd5
102 changed files with 19151 additions and 2673 deletions
-10
View File
@@ -1,10 +0,0 @@
SUB_TOKEN=$SUB_TOKEN
UI_URL=$UI_URL
UI_LOGIN=$UI_LOGIN
UI_PASSWORD=$UI_PASSWORD
COUNTRY_FLAG=$COUNTRY_FLAG
NGINX_PORT=$NGINX_PORT
UI_HOST=$UI_HOST
UI_PROTO=$UI_PROTO
ROTATE_INTERVAL=$ROTATE_INTERVAL
SUB_URL=$SUB_URL
-12
View File
@@ -1,12 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY index.js .
COPY rotate.js .
COPY builders ./builders
CMD ["node", "index.js"]
File diff suppressed because it is too large Load Diff
-162
View File
@@ -1,162 +0,0 @@
const flag = process.env.COUNTRY_FLAG ?? '%F0%9F%92%AF';
export function buildInboundLink(inbound, domain, idOrPass) {
let link = "";
switch (inbound.protocol) {
case "vless": {
link = buildVlessLink(inbound, domain, idOrPass);
break;
}
case "vmess": {
link = buildVmessLink(inbound, domain, idOrPass);
break;
}
case "shadowsocks":
link = buildSsLink(inbound, domain, idOrPass);
break;
case "trojan":
link = buildTrojanLink(inbound, domain, idOrPass);
break;
}
return link;
}
function buildVlessLink(inbound, domain, uuid) {
const stream = JSON.parse(inbound.streamSettings);
const settings = JSON.parse(inbound.settings);
const network = stream.network;
const security = stream.security || "none";
const params = new URLSearchParams();
// Базовые параметры
params.set("type", network);
params.set("encryption", "none");
params.set("security", security);
/**
* ===== REALITY =====
*/
if (security === "reality") {
const r = stream.realitySettings;
params.set("pbk", r.settings.publicKey);
params.set("fp", r.settings.fingerprint || "random");
params.set("sni", r.serverNames?.[0] || "");
params.set("sid", r.shortIds?.[0] || "");
params.set("spx", '/');
// TCP Reality flow
if (network === "tcp") {
const client = settings.clients?.[0];
if (client?.flow) {
params.set("flow", client.flow);
}
}
// XHTTP Reality
if (network === "xhttp") {
const x = stream.xhttpSettings || {};
params.set("path", x.path || "/");
params.set("host", x.host || r.serverNames?.[0]);
params.set("mode", x.mode || "auto");
}
// gRPC Reality
if (network === "grpc") {
const g = stream.grpcSettings || {};
params.set("serviceName", g.serviceName || "grpc");
params.set("authority", g.authority || r.serverNames?.[0]);
}
}
/**
* ===== WS NONE =====
*/
if (network === "ws") {
const ws = stream.wsSettings || {};
params.set("path", ws.path || "/");
if (ws.headers?.Host) {
params.set("host", ws.headers.Host);
}
}
return (
`vless://${uuid}@${domain}:${inbound.port}` +
`?${params.toString()}` +
`#${flag}%20${encodeURIComponent(inbound.remark)}`
);
}
function buildVmessLink(inbound, domain, uuid) {
const stream = JSON.parse(inbound.streamSettings);
const vmessObj = {
add: domain,
aid: '',
alpn: "",
fp: "",
host: "",
id: uuid,
net: stream.network || "tcp",
path: "/",
port: inbound.port,
ps: decodeURIComponent(flag) + ' ' + inbound.remark,
scy: "",
sni: "",
tls: stream.security || "none",
type: "none",
v: "2"
};
const base64 = Buffer
.from(JSON.stringify(vmessObj), "utf8")
.toString("base64");
return `vmess://${base64}`;
}
function buildSsLink(inbound, domain) {
const settings = JSON.parse(inbound.settings);
const method = settings.method;
const serverPassword = settings.password;
const clientPassword = settings.clients[0].password;
// method:serverPassword:clientPassword
const userInfo = `${method}:${serverPassword}:${clientPassword}`;
const base64 = Buffer
.from(userInfo, "utf8")
.toString("base64");
return `ss://${base64}@${domain}:${inbound.port}?type=tcp#${flag}%20${inbound.remark}`;
}
function buildTrojanLink(inbound, domain, password) {
const stream = JSON.parse(inbound.streamSettings);
const reality = stream.realitySettings;
const pbk = reality.settings.publicKey;
const sni = reality.serverNames?.[0] || domain;
const sid = reality.shortIds?.[0] || "";
const spx = '%2F';
return (
`trojan://${password}@${domain}:${inbound.port}` +
`?type=tcp` +
`&security=reality` +
`&pbk=${pbk}` +
`&fp=random` +
`&sni=${sni}` +
`&sid=${sid}` +
`&spx=${spx}` +
`#${flag}%20${inbound.remark}`
);
}
-43
View File
@@ -1,43 +0,0 @@
import crypto from "crypto";
export function buildShadowsocksTcp({ port, uuid }) {
return {
enable: true,
port,
protocol: "shadowsocks",
remark: "shadowsocks-tcp",
settings: JSON.stringify({
clients: [{
id: "",
flow: "",
email: uuid,
password: crypto.randomBytes(32).toString("base64"),
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
ivCheck: false,
method: "2022-blake3-aes-256-gcm",
network: "tcp",
password: crypto.randomBytes(32).toString("base64")
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "none",
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-65
View File
@@ -1,65 +0,0 @@
import crypto from "crypto";
export function buildTrojanRealityTcp({ port, uuid, domain, keys }) {
return {
enable: true,
port,
protocol: "trojan",
remark: "trojan-reality-tcp",
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
password: crypto.randomBytes(8).toString("hex"),
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "reality",
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: keys.privateKey,
shortIds: [
crypto.randomBytes(4).toString("hex"),
crypto.randomBytes(3).toString("hex"),
crypto.randomBytes(8).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(4).toString("hex")
],
settings: {
publicKey: keys.publicKey,
fingerprint: "random",
serverName: "",
spiderX: "/"
}
},
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-67
View File
@@ -1,67 +0,0 @@
import crypto from "crypto";
export function buildVlessRealityGrpc({ port, uuid, domain, keys }) {
return {
enable: true,
port,
protocol: "vless",
remark: "vless-reality-grpc",
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
decryption: "none",
encryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "grpc",
security: "reality",
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: keys.privateKey,
shortIds: [
crypto.randomBytes(4).toString("hex"),
crypto.randomBytes(3).toString("hex"),
crypto.randomBytes(8).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(4).toString("hex")
],
settings: {
publicKey: keys.publicKey,
fingerprint: "random",
serverName: "",
spiderX: "/"
}
},
grpcSettings: {
serviceName: "myservice",
authority: domain,
multiMode: false,
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-66
View File
@@ -1,66 +0,0 @@
import crypto from "crypto";
export function buildVlessRealityTcp({ port, uuid, domain, keys }) {
return {
enable: true,
port,
protocol: "vless",
remark: "vless-reality-tcp",
settings: JSON.stringify({
clients: [{
id: uuid,
flow: "xtls-rprx-vision",
email: uuid,
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
decryption: "none",
encryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "reality",
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: keys.privateKey,
shortIds: [
crypto.randomBytes(4).toString("hex"),
crypto.randomBytes(3).toString("hex"),
crypto.randomBytes(8).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(4).toString("hex")
],
settings: {
publicKey: keys.publicKey,
fingerprint: "random",
serverName: "",
spiderX: "/"
}
},
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-72
View File
@@ -1,72 +0,0 @@
import crypto from "crypto";
export function buildVlessRealityXhttp({ port, uuid, domain, keys }) {
return {
enable: true,
port,
protocol: "vless",
remark: "vless-reality-xhttp",
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
decryption: "none",
encryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "xhttp",
security: "reality",
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: keys.privateKey,
shortIds: [
crypto.randomBytes(4).toString("hex"),
crypto.randomBytes(3).toString("hex"),
crypto.randomBytes(8).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(4).toString("hex")
],
settings: {
publicKey: keys.publicKey,
fingerprint: "random",
serverName: "",
spiderX: "/"
}
},
xhttpSettings: {
host: domain,
path: "/",
mode: "auto",
noSSEHeader: false,
scMaxBufferedPosts: 30,
scMaxEachPostBytes: "1000000",
scStreamUpServerSecs: "20-80",
xPaddingBytes: "100-1000"
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-42
View File
@@ -1,42 +0,0 @@
export function buildVlessWs({ port, uuid, domain }) {
return {
enable: true,
port,
protocol: "vless",
remark: "vless-reality-ws",
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
decryption: "none",
encryption: "none",
fallbacks: []
}),
streamSettings: JSON.stringify({
network: "ws",
security: "none",
externalProxy: [],
wsSettings: {
host: domain,
path: "/",
acceptProxyProtocol: false,
heartbeatPeriod: 0,
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-37
View File
@@ -1,37 +0,0 @@
export function buildVmessTcp({ port, uuid }) {
return {
enable: true,
port,
protocol: "vmess",
remark: "vmess-tcp",
settings: JSON.stringify({
clients: [{
id: uuid,
flow: "",
email: uuid,
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "0",
alterId: "0",
reset: 0
}],
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "none",
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
metadataOnly: false,
routeOnly: false
})
};
}
-18
View File
@@ -1,18 +0,0 @@
services:
node:
build: ./app
env_file: .env
volumes:
- ./subscriptions:/subscriptions
- ./whitelist.txt:/app/whitelist.txt:ro
restart: unless-stopped
nginx:
image: nginx:alpine
restart: unless-stopped
depends_on: [node]
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./subscriptions:/subscriptions
-229
View File
@@ -1,229 +0,0 @@
import axios from "axios";
import https from "https";
import fs from "fs";
import net from "net";
import { buildVlessRealityTcp } from "./builders/buildVlessRealityTcp.js";
import { buildVlessRealityXhttp } from "./builders/buildVlessRealityXhttp.js";
import { buildTrojanRealityTcp } from "./builders/buildTrojanRealityTcp.js";
import { buildShadowsocksTcp } from "./builders/buildShadowsocksTcp.js";
import { buildVmessTcp } from "./builders/buildVmessTcp.js";
import { buildVlessRealityGrpc } from "./builders/buildVlessRealityGrpc.js";
import { buildVlessWs } from "./builders/buildVlessWs.js";
import { buildInboundLink } from "./builders/buildInboundLink.js";
const { UI_URL, UI_LOGIN, UI_PASSWORD, UI_HOST } = process.env;
const SUB_FILE = "/subscriptions/list.txt";
const LAST_INBOUNDS = '/subscriptions/latestInbounds.json';
const cookieJar = {};
const agent = new https.Agent({
rejectUnauthorized: false
});
const api = axios.create({ baseURL: UI_URL, timeout: 15000, withCredentials: true, httpsAgent: agent });
api.interceptors.request.use(config => {
if (cookieJar.value) config.headers['Cookie'] = cookieJar.value;
return config;
});
const WHITELIST_FILE = "/app/whitelist.txt";
const WHITELIST_REPO_URL = "https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/whitelist.txt";
// Helper functions
async function updateWhitelist() {
try {
const res = await axios.get(WHITELIST_REPO_URL, { timeout: 10000 });
fs.writeFileSync(WHITELIST_FILE, res.data, "utf8");
console.log("✔ whitelist.txt обновлен из репозитория");
} catch (err) {
console.warn("⚠ Не удалось обновить whitelist.txt, будет использоваться локальный файл:", err.message);
}
}
function loadWhitelist() {
let fileToUse = WHITELIST_FILE;
if (fs.existsSync("/app/my_whitelist.txt")) {
console.log('Найден кастомный whitelist: /app/my_whitelist.txt. Используется он.');
fileToUse = "/app/my_whitelist.txt";
} else {
console.log(`Кастомный whitelist не найден. Используется дефолтный: ${WHITELIST_FILE}`);
if (!fs.existsSync(WHITELIST_FILE)) {
throw new Error("Дефолтный whitelist.txt не найден");
}
}
return fs.readFileSync(fileToUse, "utf8")
.split("\n")
.map(v => v.trim())
.filter(Boolean);
}
function pickDomain(list) {
return list[Math.floor(Math.random() * list.length)];
}
async function isPortFree(port) {
return new Promise(resolve => {
const s = net.createServer()
.once("error", () => resolve(false))
.once("listening", () => {
s.close();
resolve(true);
})
.listen(port, "0.0.0.0");
});
}
async function getFreePort(used) {
while (true) {
const p = Math.floor(Math.random() * (60000 - 10000)) + 10000;
if (used.has(p)) continue;
if (await isPortFree(p)) {
used.add(p);
return p;
}
}
}
async function generateRealityKeys() {
try {
const res = await api.get("/panel/api/server/getNewX25519Cert");
if (res.data?.success && res.data?.obj) {
return {
privateKey: res.data.obj.privateKey,
publicKey: res.data.obj.publicKey
};
}
throw new Error("Invalid key response");
} catch (e) {
console.error("Failed to get Reality keys:", e.message);
throw e;
}
}
async function uuid() {
try {
const res = await api.get("/panel/api/server/getNewUUID");
if (res.data?.success && res.data?.obj?.uuid) {
return res.data.obj.uuid;
}
throw new Error("Invalid UUID response");
} catch (e) {
console.error("Failed to get UUID:", e.message);
throw e;
}
}
// API 3x-ui
async function login() {
try {
const res = await api.post("/login", { username: UI_LOGIN, password: UI_PASSWORD });
if (res.headers['set-cookie']) cookieJar.value = res.headers['set-cookie'].join('; ');
console.log("Login success");
} catch (e) { console.error("Login failed:", e.message); throw e; }
}
// async function getInbounds() {
// try {
// const res = await api.get("/panel/api/inbounds/list");
// return res.status === 200 ? res.data.obj || [] : [];
// } catch (e) { console.error("Get inbounds failed:", e.message); return []; }
// }
async function deleteInbounds() {
try {
const data = fs.readFileSync(LAST_INBOUNDS, 'utf-8');
const oldIds = JSON.parse(data);
if (Array.isArray(oldIds) && oldIds.length > 0) {
console.log(`Удаляем старые инбаунды: ${oldIds.length} шт.`);
for (const id of oldIds) {
try {
await deleteInbound(id);
} catch (err) {
console.error(`Ошибка при удалении инбаунда ${id}:`, err.message);
}
}
}
} catch (error) {
console.log("Старых ID не найдено, пропускаем удаление.");
}
}
async function deleteInbound(id) {
try { await api.post(`/panel/api/inbounds/del/${id}`); } catch { }
}
async function addInbound(config) {
try {
const res = await api.post("/panel/api/inbounds/add", config);
return res.data?.obj?.id || null;
} catch (e) {
console.error("Add inbound failed:", e.message);
return null;
}
}
// Main rotation of inbound entries
async function rotate() {
await login();
await updateWhitelist();
const whitelist = loadWhitelist();
const usedPorts = new Set();
const subs = [];
await deleteInbounds();
const builders = [
async (d) => buildVlessRealityTcp({ port: await isPortFree(8443) ? 8443 : await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityXhttp({ port: await isPortFree(443) ? 443 : await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityGrpc({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessWs({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d }),
async (d) => buildVlessRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVmessTcp({ port: await getFreePort(usedPorts), uuid: await uuid() }),
async (d) => buildShadowsocksTcp({ port: await getFreePort(usedPorts), uuid: await uuid() }),
async (d) => buildTrojanRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
];
const inboundIds = [];
for (const b of builders) {
const domain = pickDomain(whitelist);
const inbound = await b(domain);
const idOrPass = inbound.settings ? JSON.parse(inbound.settings).clients?.[0]?.id || JSON.parse(inbound.settings).clients?.[0]?.password : "";
// Build the link depending on the protocol
const link = buildInboundLink(inbound, UI_HOST, idOrPass);
if (link) subs.push(link);
const id = await addInbound(inbound);
inboundIds.push(id);
}
if (inboundIds.length > 0) {
fs.writeFileSync(LAST_INBOUNDS, JSON.stringify(inboundIds, null, 2));
console.log(`Новые ID (${inboundIds.length} шт.) сохранены в ${LAST_INBOUNDS}`);
}
fs.writeFileSync(SUB_FILE, subs.join("\n") + "\n", "utf8");
console.log("✔ 10 inbound created, подписка обновлена");
}
// Initial run
rotate();
let interval = parseInt(process.env.ROTATE_INTERVAL, 10);
if (isNaN(interval) || interval < 10) {
console.warn('⚠ Интервал некорректен или меньше 10 минут. Используется значение по умолчанию 30 минут.');
interval = 30;
}
const intervalMs = interval * 60 * 1000;
console.log(`✔ Интервал ротации установлен: ${interval} минут`);
setInterval(rotate, intervalMs);
-18
View File
@@ -1,18 +0,0 @@
events {}
http {
server {
listen $NGINX_PORT;
server_name $UI_HOST;
location = /bus/$SUB_TOKEN {
alias /subscriptions/list.txt;
default_type text/plain;
add_header Subscription-Userinfo "upload=0; download=0; total=109951162777600; expire=0" always;
add_header Access-Control-Allow-Origin *;
}
location / {
return 404;
}
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"type": "module",
"dependencies": {
"axios": "^1.13.2",
"node-cron": "^4.2.1"
}
}
-216
View File
@@ -1,216 +0,0 @@
import axios from "axios";
import https from "https";
import fs from "fs";
import net from "net";
import { buildVlessRealityTcp } from "./builders/buildVlessRealityTcp.js";
import { buildVlessRealityXhttp } from "./builders/buildVlessRealityXhttp.js";
import { buildTrojanRealityTcp } from "./builders/buildTrojanRealityTcp.js";
import { buildShadowsocksTcp } from "./builders/buildShadowsocksTcp.js";
import { buildVmessTcp } from "./builders/buildVmessTcp.js";
import { buildVlessRealityGrpc } from "./builders/buildVlessRealityGrpc.js";
import { buildVlessWs } from "./builders/buildVlessWs.js";
import { buildInboundLink } from "./builders/buildInboundLink.js";
const { UI_URL, UI_LOGIN, UI_PASSWORD, UI_HOST } = process.env;
const SUB_FILE = "/subscriptions/list.txt";
const LAST_INBOUNDS = '/subscriptions/latestInbounds.json';
const cookieJar = {};
const agent = new https.Agent({
rejectUnauthorized: false
});
const api = axios.create({ baseURL: UI_URL, timeout: 15000, withCredentials: true, httpsAgent: agent });
api.interceptors.request.use(config => {
if (cookieJar.value) config.headers['Cookie'] = cookieJar.value;
return config;
});
const WHITELIST_FILE = "/app/whitelist.txt";
const WHITELIST_REPO_URL = "https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/whitelist.txt";
// Helper functions
async function updateWhitelist() {
try {
const res = await axios.get(WHITELIST_REPO_URL, { timeout: 10000 });
fs.writeFileSync(WHITELIST_FILE, res.data, "utf8");
console.log("✔ whitelist.txt обновлен из репозитория");
} catch (err) {
console.warn("⚠ Не удалось обновить whitelist.txt, будет использоваться локальный файл:", err.message);
}
}
function loadWhitelist() {
let fileToUse = WHITELIST_FILE;
if (fs.existsSync("/app/my_whitelist.txt")) {
console.log('Найден кастомный whitelist: /app/my_whitelist.txt. Используется он.');
fileToUse = "/app/my_whitelist.txt";
} else {
console.log(`Кастомный whitelist не найден. Используется дефолтный: ${WHITELIST_FILE}`);
if (!fs.existsSync(WHITELIST_FILE)) {
throw new Error("Дефолтный whitelist.txt не найден");
}
}
return fs.readFileSync(fileToUse, "utf8")
.split("\n")
.map(v => v.trim())
.filter(Boolean);
}
function pickDomain(list) {
return list[Math.floor(Math.random() * list.length)];
}
async function isPortFree(port) {
return new Promise(resolve => {
const s = net.createServer()
.once("error", () => resolve(false))
.once("listening", () => {
s.close();
resolve(true);
})
.listen(port, "0.0.0.0");
});
}
async function getFreePort(used) {
while (true) {
const p = Math.floor(Math.random() * (60000 - 10000)) + 10000;
if (used.has(p)) continue;
if (await isPortFree(p)) {
used.add(p);
return p;
}
}
}
async function generateRealityKeys() {
try {
const res = await api.get("/panel/api/server/getNewX25519Cert");
if (res.data?.success && res.data?.obj) {
return {
privateKey: res.data.obj.privateKey,
publicKey: res.data.obj.publicKey
};
}
throw new Error("Invalid key response");
} catch (e) {
console.error("Failed to get Reality keys:", e.message);
throw e;
}
}
async function uuid() {
try {
const res = await api.get("/panel/api/server/getNewUUID");
if (res.data?.success && res.data?.obj?.uuid) {
return res.data.obj.uuid;
}
throw new Error("Invalid UUID response");
} catch (e) {
console.error("Failed to get UUID:", e.message);
throw e;
}
}
// API 3x-ui
async function login() {
try {
const res = await api.post("/login", { username: UI_LOGIN, password: UI_PASSWORD });
if (res.headers['set-cookie']) cookieJar.value = res.headers['set-cookie'].join('; ');
console.log("Login success");
} catch (e) { console.error("Login failed:", e.message); throw e; }
}
// async function getInbounds() {
// try {
// const res = await api.get("/panel/api/inbounds/list");
// return res.status === 200 ? res.data.obj || [] : [];
// } catch (e) { console.error("Get inbounds failed:", e.message); return []; }
// }
async function deleteInbounds() {
try {
const data = fs.readFileSync(LAST_INBOUNDS, 'utf-8');
const oldIds = JSON.parse(data);
if (Array.isArray(oldIds) && oldIds.length > 0) {
console.log(`Удаляем старые инбаунды: ${oldIds.length} шт.`);
for (const id of oldIds) {
try {
await deleteInbound(id);
} catch (err) {
console.error(`Ошибка при удалении инбаунда ${id}:`, err.message);
}
}
}
} catch (error) {
console.log("Старых ID не найдено, пропускаем удаление.");
}
}
async function deleteInbound(id) {
try { await api.post(`/panel/api/inbounds/del/${id}`); } catch { }
}
async function addInbound(config) {
try {
const res = await api.post("/panel/api/inbounds/add", config);
return res.data?.obj?.id || null;
} catch (e) {
console.error("Add inbound failed:", e.message);
return null;
}
}
// Main rotation of inbound entries
async function rotate() {
await login();
await updateWhitelist();
const whitelist = loadWhitelist();
const usedPorts = new Set();
const subs = [];
await deleteInbounds();
const builders = [
async (d) => buildVlessRealityTcp({ port: await isPortFree(8443) ? 8443 : await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityXhttp({ port: await isPortFree(443) ? 443 : await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityGrpc({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessWs({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d }),
async (d) => buildVlessRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVlessRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
async (d) => buildVmessTcp({ port: await getFreePort(usedPorts), uuid: await uuid() }),
async (d) => buildShadowsocksTcp({ port: await getFreePort(usedPorts), uuid: await uuid() }),
async (d) => buildTrojanRealityTcp({ port: await getFreePort(usedPorts), uuid: await uuid(), domain: d, keys: await generateRealityKeys() }),
];
const inboundIds = [];
for (const b of builders) {
const domain = pickDomain(whitelist);
const inbound = await b(domain);
const idOrPass = inbound.settings ? JSON.parse(inbound.settings).clients?.[0]?.id || JSON.parse(inbound.settings).clients?.[0]?.password : "";
// Build the link depending on the protocol
const link = buildInboundLink(inbound, UI_HOST, idOrPass);
if (link) subs.push(link);
const id = await addInbound(inbound);
inboundIds.push(id);
}
if (inboundIds.length > 0) {
fs.writeFileSync(LAST_INBOUNDS, JSON.stringify(inboundIds, null, 2));
console.log(`Новые ID (${inboundIds.length} шт.) сохранены в ${LAST_INBOUNDS}`);
}
fs.writeFileSync(SUB_FILE, subs.join("\n") + "\n", "utf8");
console.log("✔ 10 inbound created, подписка обновлена");
}
// Initial run
rotate();
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link data-rh="true" rel="icon" href="/img/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3DP-MANAGER</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4290
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/inter": "^5.2.8",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-qr-code": "^2.0.18",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 KiB

+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import SubscriptionsPage from './pages/SubscriptionsPage';
import SettingsPage from './pages/SettingsPage';
import DomainsPage from './pages/DomainsPage';
import LoginPage from './pages/LoginPage';
import { ThemeProvider } from './ThemeContext';
import { AuthProvider } from './auth/AuthContext';
import RequireAuth from './auth/RequireAuth';
import NotFoundPage from './pages/NotFoundPage';
import { AxiosInterceptor } from './auth/AxiosInterceptor';
import PublicRoute from './auth/PublicRoute';
import TunnelsPage from './pages/TunnelsPage';
function App() {
return (
<AuthProvider>
<ThemeProvider>
<BrowserRouter>
<AxiosInterceptor />
<Routes>
<Route element={<PublicRoute />}>
<Route path="/login" element={<LoginPage />} />
</Route>
<Route path="/" element={
<RequireAuth>
<Layout />
</RequireAuth>
}>
<Route index element={<SubscriptionsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="domains" element={<DomainsPage />} />
<Route path="tunnels" element={<TunnelsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
</ThemeProvider>
</AuthProvider>
);
}
export default App;
+63
View File
@@ -0,0 +1,63 @@
import React, { createContext, useState, useMemo, useContext, useEffect } from 'react';
import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
import useMediaQuery from '@mui/material/useMediaQuery';
// Импортируем нашу настройку
import { getDesignTokens } from './theme';
type ColorMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
mode: ColorMode;
toggleColorMode: () => void;
}
const ThemeContext = createContext<ThemeContextType>({} as ThemeContextType);
export const useThemeContext = () => useContext(ThemeContext);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// Читаем из localStorage или ставим 'system'
const [mode, setMode] = useState<ColorMode>(() => {
return (localStorage.getItem('themeMode') as ColorMode) || 'system';
});
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
useEffect(() => {
localStorage.setItem('themeMode', mode);
}, [mode]);
const toggleColorMode = () => {
setMode((prevMode) => {
if (prevMode === 'light') return 'dark';
if (prevMode === 'dark') return 'system';
return 'light';
});
};
// Вычисляем реальную тему (light/dark) на основе настроек и системы
const theme = useMemo(() => {
let activeMode: ColorMode;
if (mode === 'system') {
activeMode = prefersDarkMode ? 'dark' : 'light';
} else {
activeMode = mode;
}
// ВАЖНО: Используем нашу функцию getDesignTokens
const themeOptions = getDesignTokens(activeMode);
return createTheme(themeOptions);
}, [mode, prefersDarkMode]);
return (
<ThemeContext.Provider value={{ mode, toggleColorMode }}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>
);
};
+7
View File
@@ -0,0 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:3000/api',
});
export default api;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+36
View File
@@ -0,0 +1,36 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import api from '../api';
interface AuthContextType {
token: string | null;
login: (token: string) => void;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType>(null!);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
useEffect(() => {
if (token) {
localStorage.setItem('token', token);
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'];
}
}, [token]);
const login = (newToken: string) => setToken(newToken);
const logout = () => setToken(null);
return (
<AuthContext.Provider value={{ token, login, logout, isAuthenticated: !!token }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
+29
View File
@@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { useAuth } from './AuthContext';
export function AxiosInterceptor() {
const { logout } = useAuth();
const navigate = useNavigate();
useEffect(() => {
const interceptor = api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
console.warn('Session expired or unauthorized. Logging out...');
logout();
navigate('/login');
}
return Promise.reject(error);
}
);
return () => {
api.interceptors.response.eject(interceptor);
};
}, [logout, navigate]);
return null;
}
+12
View File
@@ -0,0 +1,12 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from './AuthContext';
export default function PublicRoute() {
const { isAuthenticated } = useAuth();
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
return <Outlet />;
}
+14
View File
@@ -0,0 +1,14 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
import type React from 'react';
export default function RequireAuth({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
+86
View File
@@ -0,0 +1,86 @@
import React from 'react';
import { Box, Container, Grid, Typography, IconButton, Link, Stack, useTheme } from '@mui/material';
import { GitHub, YouTube, Telegram, Article } from '@mui/icons-material';
export default function Footer() {
const theme = useTheme();
return (
<Box
component="footer"
sx={{
py: 3,
px: 2,
mt: 'auto', // Ключевой стиль для прижатия к низу
backgroundColor: (theme) =>
theme.palette.mode === 'light'
? theme.palette.grey[200]
: theme.palette.grey[900],
}}
>
<Container maxWidth={false}>
<Grid container spacing={4} justifyContent="space-between" alignItems="center">
{/* Логотип и копирайт */}
<Grid size={{ xs: 12, sm: 4 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
</Stack>
</Grid>
{/* Ссылка на документацию */}
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'center' } }}>
<Link
href="https://3dp-manager.com/docs/intro" // Ссылка на ваш репо или доку
target="_blank"
rel="noopener"
color="text.primary"
underline="hover"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, fontWeight: 500 }}
>
Документация
</Link>
</Grid>
{/* Социальные иконки */}
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'right' } }}>
<Stack direction="row" spacing={1} justifyContent={{ xs: 'flex-start', sm: 'flex-end' }}>
<IconButton
component="a"
href="https://github.com/denpiligrim"
target="_blank"
aria-label="GitHub"
color="inherit"
>
<GitHub />
</IconButton>
<IconButton
component="a"
href="https://youtube.com/@denpiligrim"
target="_blank"
aria-label="YouTube"
color="inherit"
>
<YouTube />
</IconButton>
<IconButton
component="a"
href="https://t.me/denpiligrim_web"
target="_blank"
aria-label="Telegram"
color="inherit"
>
<Telegram />
</IconButton>
</Stack>
</Grid>
</Grid>
</Container>
</Box>
);
}
+140
View File
@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import {
AppBar, Toolbar, Typography, IconButton, Tooltip, Box,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button, List, ListItem, ListItemText
} from '@mui/material';
import {
Brightness7, Brightness4, BrightnessAuto,
Logout, HelpOutline
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useThemeContext } from '../ThemeContext';
import { useAuth } from '../auth/AuthContext';
export default function Header() {
const { mode, toggleColorMode } = useThemeContext();
const { logout } = useAuth();
const navigate = useNavigate();
// Состояние для модального окна справки
const [helpOpen, setHelpOpen] = useState(false);
// Логика выхода
const handleLogout = () => {
if (confirm('Вы действительно хотите выйти?')) {
logout();
navigate('/login');
}
};
const getThemeIcon = () => {
switch (mode) {
case 'light': return <Brightness7 />;
case 'dark': return <Brightness4 />;
case 'system': return <BrightnessAuto />;
}
};
const getThemeLabel = () => {
switch (mode) {
case 'light': return 'Светлая тема';
case 'dark': return 'Темная тема';
case 'system': return 'Системная тема';
}
};
return (
<>
<AppBar
position="fixed"
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
>
<Toolbar>
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold', color: '#1395de' }}>
3DP-MANAGER
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
{/* Кнопка Справки */}
<Tooltip title="Справка о программе">
<IconButton color="inherit" onClick={() => setHelpOpen(true)}>
<HelpOutline />
</IconButton>
</Tooltip>
{/* Кнопка Темы */}
<Tooltip title={`Режим: ${getThemeLabel()}`}>
<IconButton color="inherit" onClick={toggleColorMode}>
{getThemeIcon()}
</IconButton>
</Tooltip>
{/* Кнопка Выхода */}
<Tooltip title="Выйти из системы">
<IconButton color="inherit" onClick={handleLogout}>
<Logout />
</IconButton>
</Tooltip>
</Box>
</Toolbar>
</AppBar>
{/* Модальное окно справки */}
<Dialog
open={helpOpen}
onClose={() => setHelpOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Об утилите 3DP-MANAGER</DialogTitle>
<DialogContent dividers>
<DialogContentText paragraph>
Утилита для автогенерации инбаундов к панели 3x-ui, формирования единой подписки и настройки перенаправления трафика с промежуточного сервера на основной.
</DialogContentText>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
Основные возможности:
</Typography>
<List dense>
<ListItem>
<ListItemText
primary="Автоматическая генерация"
secondary="Система создает новые инбаунды в заданном интервале."
/>
</ListItem>
<ListItem>
<ListItemText
primary="Управление подписками"
secondary="Создание пользователей с уникальными UUID. Одна подписка генерирует множество подключений."
/>
</ListItem>
<ListItem>
<ListItemText
primary="Белый список доменов"
secondary="Для работы инбаундов необходим список доменов, под которые маскируется трафик."
/>
</ListItem>
<ListItem>
<ListItemText
primary="Перенаправление"
secondary="Если вы используете Каскадную схему подключения, то вы сможете добавить свои промежуточные сервера."
/>
</ListItem>
</List>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Версия: 2.0.0<br />
Разработчик: DenPiligrim
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setHelpOpen(false)}>Понятно</Button>
</DialogActions>
</Dialog>
</>
);
}
+81
View File
@@ -0,0 +1,81 @@
import React from 'react';
import {
Toolbar, Drawer, List, ListItem,
ListItemButton, ListItemIcon, ListItemText, Box
} from '@mui/material';
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import Header from './Header'; // <--- Новый компонент
import Footer from './Footer';
const drawerWidth = 240;
export default function Layout() {
const navigate = useNavigate();
const location = useLocation();
const menuItems = [
{ text: 'Подписки', icon: <People />, path: '/' },
{ text: 'Домены', icon: <Dns />, path: '/domains' },
{ text: 'Перенаправление', icon: <SwapHoriz />, path: '/tunnels' },
{ text: 'Настройки', icon: <Settings />, path: '/settings' },
];
return (
<Box sx={{ display: 'flex', minHeight: '100vh', width: '100%' }}>
{/* Шапка */}
<Header />
{/* Боковое меню */}
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' },
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => navigate(item.path)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Drawer>
{/* Основной контейнер контента */}
<Box
component="main"
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
width: '100%'
}}
>
<Toolbar />
{/* Контент страницы */}
<Box sx={{ flexGrow: 1, p: 3 }}>
<Outlet />
</Box>
{/* Футер */}
<Footer />
</Box>
</Box>
);
}
View File
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import '@fontsource/inter/300.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/700.css';
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+165
View File
@@ -0,0 +1,165 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, TextField, Button, Typography, List, ListItem, ListItemText, IconButton, Paper, TablePagination } from '@mui/material';
import { Delete, Add, DeleteSweep, UploadFile, Remove } from '@mui/icons-material';
import api from '../api';
interface Domain { id: number; name: string; }
export default function DomainsPage() {
const [domains, setDomains] = useState<Domain[]>([]);
const [newDomain, setNewDomain] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [totalCount, setTotalCount] = useState(0); // Общее кол-во записей в БД
// Состояние пагинации
const [page, setPage] = useState(0); // MUI использует индекс с 0
const [rowsPerPage, setRowsPerPage] = useState(10); // По умолчанию 10
useEffect(() => {
loadDomains();
}, [page, rowsPerPage]);
const loadDomains = async () => {
try {
// Backend ждет page начиная с 1, а MUI дает с 0. Поэтому page + 1
const { data } = await api.get(`/domains?page=${page + 1}&limit=${rowsPerPage}`);
// Сервер теперь возвращает { data: [], total: 123 }
setDomains(data.data);
setTotalCount(data.total);
} catch (e) {
console.error(e);
}
};
// Обработчик смены страницы
const handleChangePage = (event: unknown, newPage: number) => {
setPage(newPage);
};
// Обработчик смены кол-ва строк на странице
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0); // Сбрасываем на первую страницу
};
const handleAdd = async () => {
if (!newDomain) return;
await api.post('/domains', { name: newDomain });
setNewDomain('');
loadDomains();
};
const handleDelete = async (id: number) => {
await api.delete(`/domains/${id}`);
loadDomains();
};
const handleDeleteAll = async () => {
if (confirm('ВНИМАНИЕ! Вы действительно хотите удалить ВСЕ домены из белого списка?')) {
if (confirm('Это действие необратимо. Точно удалить?')) {
try {
await api.delete('/domains/all');
loadDomains();
} catch (e) { alert('Ошибка удаления'); }
}
}
};
// --- ЛОГИКА ЗАГРУЗКИ ФАЙЛА ---
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
const text = e.target?.result as string;
if (!text) return;
// Разбиваем текст на строки по переносу
const lines = text.split(/\r?\n/);
// Отправляем на сервер
try {
const { data } = await api.post('/domains/upload', { domains: lines });
alert(`Успешно добавлено доменов: ${data.count}`);
loadDomains();
} catch (err) {
alert('Ошибка при загрузке списка');
} finally {
// Сбрасываем инпут, чтобы можно было загрузить тот же файл повторно
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
reader.readAsText(file);
};
return (
<Box>
<Typography variant="h4" gutterBottom>Белый список доменов (SNI)</Typography>
<Paper sx={{ p: 2, display: 'flex', gap: 2 }}>
<TextField
label="Добавить домен" size="small" fullWidth
value={newDomain} onChange={(e) => setNewDomain(e.target.value)}
/>
<Button
variant="outlined"
startIcon={<UploadFile />}
sx={{ width: '170px' }}
onClick={() => fileInputRef.current?.click()}
>
Из файла
</Button>
{/* Скрытый инпут */}
<input
type="file"
accept=".txt"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
<Button variant="contained" sx={{ width: '160px' }} startIcon={<Add />} onClick={handleAdd}>Добавить</Button>
</Paper>
{domains.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'end', width: '100%' }}>
<Button
variant="text"
color="error"
size='small'
startIcon={<Remove />}
onClick={handleDeleteAll}
>
Удалить все
</Button>
</Box>
)}
<Paper>
<List>
{domains.map((d) => (
<ListItem key={d.id} secondaryAction={
<IconButton edge="end" onClick={() => handleDelete(d.id)}><Delete /></IconButton>
}>
<ListItemText primary={d.name} />
</ListItem>
))}
{domains.length === 0 && <Typography sx={{ p: 2 }} color='textSecondary' textAlign='center'>Список пуст</Typography>}
</List>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage="Доменов на странице:"
labelDisplayedRows={({ from, to, count }) => `${from}${to} из ${count !== -1 ? count : `более ${to}`}`}
/>
</Paper>
</Box>
);
}
+50
View File
@@ -0,0 +1,50 @@
import React, { useState } from 'react';
import { Box, Paper, TextField, Button, Typography, Alert } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { useAuth } from '../auth/AuthContext';
export default function LoginPage() {
const [creds, setCreds] = useState({ login: '', password: '' });
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const res = await api.post('/auth/login', creds);
login(res.data.access_token);
navigate('/');
} catch (e) {
setError('Неверный логин или пароль');
}
};
return (
<Box sx={{
height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: 'background.default'
}}>
<Paper sx={{ p: 4, width: '100%', maxWidth: 400 }}>
<Typography variant="h5" gutterBottom align="center">Вход в 3DP-MANAGER</Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<form onSubmit={handleSubmit}>
<TextField
fullWidth margin="normal" label="Логин"
value={creds.login} onChange={(e) => setCreds({ ...creds, login: e.target.value })}
/>
<TextField
fullWidth margin="normal" label="Пароль" type="password"
value={creds.password} onChange={(e) => setCreds({ ...creds, password: e.target.value })}
/>
<Button fullWidth variant="contained" size="large" type="submit" sx={{ mt: 3 }}>
Войти
</Button>
</form>
</Paper>
</Box>
);
}
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
import { Box, Typography, Button, Container } from '@mui/material';
import { useNavigate } from 'react-router-dom';
export default function NotFoundPage() {
const navigate = useNavigate();
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: 'background.default',
}}
>
<Container maxWidth="md" sx={{ textAlign: 'center' }}>
<Typography variant="h1" color="primary" sx={{ fontWeight: 'bold' }}>
404
</Typography>
<Typography variant="h5" color="text.secondary" gutterBottom>
Страница не найдена
</Typography>
<Button
variant="contained"
size="large"
onClick={() => navigate('/')}
>
На главную
</Button>
</Container>
</Box>
);
}
+163
View File
@@ -0,0 +1,163 @@
import React, { useEffect, useState } from 'react';
import { Box, TextField, Button, Typography, Paper, Snackbar, Alert, Grid, Divider, InputAdornment } from '@mui/material';
import api from '../api';
import { useAuth } from '../auth/AuthContext';
export default function SettingsPage() {
// Настройки 3x-ui и ротации
const [settings, setSettings] = useState({
xui_url: '',
xui_login: '',
xui_password: '',
rotation_interval: '30', // Значение по умолчанию
});
// Настройки админа (локальное состояние формы)
const [adminProfile, setAdminProfile] = useState({
login: '',
password: '',
});
const [msg, setMsg] = useState({ open: false, type: 'success' as 'success'|'error', text: '' });
const { logout } = useAuth(); // Чтобы разлогинить, если сменили свои данные
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const { data } = await api.get('/settings');
// Заполняем основные настройки
setSettings((prev) => ({ ...prev, ...data }));
// Логин админа тоже приходит в settings (если мы разрешили его чтение),
// но пароль (хеш) показывать нельзя.
if (data.admin_login) {
setAdminProfile((prev) => ({ ...prev, login: data.admin_login }));
}
} catch (e) {
console.error(e);
}
};
// --- Handlers для настроек 3x-ui и ротации ---
const handleSettingChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setSettings({ ...settings, [prop]: event.target.value });
};
const handleSaveSettings = async () => {
try {
// Отправляем всё, что в settings
await api.post('/settings', settings);
setMsg({ open: true, type: 'success', text: 'Настройки сохранены!' });
} catch (e) {
setMsg({ open: true, type: 'error', text: 'Ошибка сохранения' });
}
};
// --- Handlers для профиля админа ---
const handleAdminChange = (prop: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setAdminProfile({ ...adminProfile, [prop]: event.target.value });
};
const handleSaveAdmin = async () => {
try {
await api.post('/auth/update-profile', adminProfile);
setMsg({ open: true, type: 'success', text: 'Профиль администратора обновлен!' });
setAdminProfile(prev => ({ ...prev, password: '' })); // Очищаем поле пароля
// Опционально: можно сделать логаут, чтобы заставить войти с новыми данными
// logout();
} catch (e) {
setMsg({ open: true, type: 'error', text: 'Ошибка обновления профиля' });
}
};
return (
<Box>
<Typography variant="h4" gutterBottom>Настройки утилиты</Typography>
<Grid container spacing={3}>
{/* БЛОК 1: Подключение к 3x-ui */}
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 3, height: '100%' }}>
<Typography variant="h6" gutterBottom>Панель 3x-ui</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="URL панели"
value={settings.xui_url} onChange={handleSettingChange('xui_url')}
helperText="Например: https://my-vpn.com:2053/panel_path"
/>
<TextField
fullWidth margin="normal" label="Логин 3x-ui"
value={settings.xui_login} onChange={handleSettingChange('xui_login')}
/>
<TextField
fullWidth margin="normal" label="Пароль 3x-ui" type="password"
value={settings.xui_password} onChange={handleSettingChange('xui_password')}
/>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
Сохранить подключение
</Button>
</Paper>
</Grid>
{/* БЛОК 2: Ротация и Админка */}
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Настройки Ротации */}
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Генерация инбаундов</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="Интервал генерации"
type="number"
value={settings.rotation_interval}
onChange={handleSettingChange('rotation_interval')}
slotProps={{
input: { endAdornment: <InputAdornment position="end">мин</InputAdornment> }
}}
helperText="Как часто менять инбаунды (минимум 10 мин)"
/>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSaveSettings}>
Применить интервал
</Button>
</Paper>
{/* Настройки Администратора */}
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>Доступ к 3DP-MANAGER</Typography>
<Divider sx={{ mb: 2 }} />
<TextField
fullWidth margin="normal" label="Логин администратора"
value={adminProfile.login}
onChange={handleAdminChange('login')}
/>
<TextField
fullWidth margin="normal" label="Новый пароль" type="password"
value={adminProfile.password}
onChange={handleAdminChange('password')}
helperText="Оставьте пустым, если не хотите менять"
/>
<Button variant="contained" color="warning" sx={{ mt: 2 }} onClick={handleSaveAdmin}>
Обновить профиль
</Button>
</Paper>
</Box>
</Grid>
</Grid>
<Snackbar open={msg.open} autoHideDuration={5000} onClose={() => setMsg({...msg, open: false})}>
<Alert severity={msg.type}>{msg.text}</Alert>
</Snackbar>
</Box>
);
}
+141
View File
@@ -0,0 +1,141 @@
import React, { useEffect, useState } from 'react';
import {
Box, Button, Typography, Paper, Table, TableBody, TableCell,
TableHead, TableRow, IconButton, Dialog, DialogTitle,
DialogContent, TextField, DialogActions
} from '@mui/material';
import { Delete, Add, Link as LinkIcon, Refresh, QrCode, Share, OpenInNew, CopyAll, ContentCopy } from '@mui/icons-material';
import api from '../api';
interface Subscription {
id: string;
name: string;
uuid: string;
inbounds: any[];
}
export default function SubscriptionsPage() {
const [subs, setSubs] = useState<Subscription[]>([]);
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
// Для модалки со ссылками
const [linksOpen, setLinksOpen] = useState(false);
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
useEffect(() => { loadSubs(); }, []);
const loadSubs = async () => {
const { data } = await api.get('/subscriptions');
setSubs(data);
};
const handleCreate = async () => {
await api.post('/subscriptions', { name });
setOpen(false);
setName('');
loadSubs();
};
const handleDelete = async (id: string) => {
if (confirm('Удалить подписку и все соединения?')) {
await api.delete(`/subscriptions/${id}`);
loadSubs();
}
};
const showLinks = (sub: Subscription) => {
const links = sub.inbounds?.map(i => i.link).filter(Boolean) || [];
if (links.length === 0) {
setCurrentLinks(['Нет активных ссылок (ждите ротации)']);
} else {
setCurrentLinks(links);
}
setLinksOpen(true);
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Подписки</Typography>
<Box>
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
</Box>
</Box>
<Paper>
<Table>
<TableHead>
<TableRow>
<TableCell>Имя</TableCell>
<TableCell>UUID</TableCell>
<TableCell>Инбаунды</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{subs.map((sub) => (
<TableRow key={sub.id}>
<TableCell>{sub.name}</TableCell>
<TableCell sx={{ fontFamily: 'monospace' }}>{sub.uuid}</TableCell>
<TableCell>{sub.inbounds?.length || 0}</TableCell>
<TableCell align="right">
<IconButton
color="primary"
onClick={() => navigator.clipboard.writeText(`http://localhost:3000/bus/${sub.uuid}`)}
title="Копировать ссылку"
>
<ContentCopy />
</IconButton>
<IconButton
color="primary"
onClick={() => window.open(`http://localhost:3000/bus/${sub.uuid}`, '_blank')}
title="Открыть подписку"
>
<OpenInNew />
</IconButton>
<IconButton color="primary" onClick={() => showLinks(sub)} title="Показать конфиги">
<LinkIcon />
</IconButton>
<IconButton color="primary" onClick={() => handleDelete(sub.id)} title="Удалить">
<Delete />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Новая подписка</DialogTitle>
<DialogContent>
<TextField
autoFocus margin="dense" label="Имя пользователя" fullWidth
value={name} onChange={(e) => setName(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>
<Button onClick={handleCreate}>Создать</Button>
</DialogActions>
</Dialog>
<Dialog open={linksOpen} onClose={() => setLinksOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Активные ссылки</DialogTitle>
<DialogContent>
<TextField
multiline fullWidth rows={10}
value={currentLinks.join('\n\n')}
slotProps={{ input: { readOnly: true, sx: { fontFamily: 'monospace', fontSize: 12 } } }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => navigator.clipboard.writeText(currentLinks.join('\n'))}>Копировать всё</Button>
<Button onClick={() => setLinksOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+143
View File
@@ -0,0 +1,143 @@
import React, { useEffect, useState } from 'react';
import {
Box, Button, Typography, Paper, Table, TableBody, TableCell,
TableHead, TableRow, IconButton, Dialog, DialogTitle,
DialogContent, TextField, DialogActions, Chip, CircularProgress
} from '@mui/material';
import { Delete, Add, Terminal, CheckCircle, Error, Dns } from '@mui/icons-material';
import api from '../api';
interface Tunnel {
id: number;
name: string;
ip: string;
sshPort: number;
username: string;
isInstalled: boolean;
}
export default function TunnelsPage() {
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [open, setOpen] = useState(false);
const [loadingId, setLoadingId] = useState<number | null>(null);
const [form, setForm] = useState({
name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: ''
});
useEffect(() => { loadTunnels(); }, []);
const loadTunnels = async () => {
try {
const { data } = await api.get('/tunnels');
setTunnels(data);
} catch (e) { console.error(e); }
};
const handleCreate = async () => {
await api.post('/tunnels', form);
setOpen(false);
setForm({ name: '', ip: '', sshPort: 22, username: 'root', password: '', domain: '' });
loadTunnels();
};
const handleDelete = async (id: number) => {
if (confirm('Удалить сервер из списка?')) {
await api.delete(`/tunnels/${id}`);
loadTunnels();
}
};
const handleInstall = async (id: number) => {
if (!confirm('Начать установку перенаправления на этот сервер?')) return;
setLoadingId(id);
try {
await api.post(`/tunnels/${id}/install`);
alert('Скрипт успешно установлен! Трафик перенаправляется.');
loadTunnels();
} catch (e: any) {
alert('Ошибка: ' + (e.response?.data?.message || e.message));
} finally {
setLoadingId(null);
}
};
const handleChange = (prop: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, [prop]: e.target.value });
};
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Редирект серверы (Туннели)</Typography>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Добавить</Button>
</Box>
<Paper>
<Table>
<TableHead>
<TableRow>
<TableCell>Название</TableCell>
<TableCell>Адрес</TableCell>
<TableCell>Статус</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tunnels.map((t) => (
<TableRow key={t.id}>
<TableCell>{t.name}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Dns fontSize="small" color="action" />
{t.ip}
</Box>
</TableCell>
<TableCell>
{t.isInstalled ?
<Chip icon={<CheckCircle />} label="Активен" color="success" size="small" variant="outlined" /> :
<Chip icon={<Error />} label="Не настроен" color="warning" size="small" variant="outlined" />
}
</TableCell>
<TableCell align="right">
<Button
startIcon={loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
disabled={loadingId !== null} // Блокируем всё, пока идет установка
onClick={() => handleInstall(t.id)}
sx={{ mr: 1 }}
variant="outlined"
size="small"
>
{loadingId === t.id ? 'Установка...' : 'Установить'}
</Button>
<IconButton color="inherit" onClick={() => handleDelete(t.id)}>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))}
{tunnels.length === 0 && <TableRow><TableCell colSpan={4} align="center">Список пуст</TableCell></TableRow>}
</TableBody>
</Table>
</Paper>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Новый редирект сервер</DialogTitle>
<DialogContent>
<TextField margin="dense" label="Название" fullWidth value={form.name} onChange={handleChange('name')} />
<TextField margin="dense" label="IP адрес" fullWidth value={form.ip} onChange={handleChange('ip')} />
<Box sx={{ display: 'flex', gap: 2 }}>
<TextField margin="dense" label="SSH Порт" type="number" fullWidth value={form.sshPort} onChange={handleChange('sshPort')} />
<TextField margin="dense" label="SSH User" fullWidth value={form.username} onChange={handleChange('username')} />
</Box>
<TextField margin="dense" label="SSH Пароль" type="password" fullWidth value={form.password} onChange={handleChange('password')} />
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>
<Button variant="contained" onClick={handleCreate}>Сохранить</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+154
View File
@@ -0,0 +1,154 @@
import type { PaletteMode } from '@mui/material';
import { amber, deepOrange, grey } from '@mui/material/colors';
// 1. Определение цветов для Светлой темы
const lightPalette = {
primary: {
main: '#2563eb', // Насыщенный синий (Tailwind Blue 600)
light: '#60a5fa',
dark: '#1e40af',
},
secondary: {
main: '#7c3aed', // Фиолетовый
},
background: {
default: '#f3f4f6', // Светло-серый фон (не чисто белый)
paper: '#ffffff', // Карточки белые
},
text: {
primary: '#111827', // Почти черный
secondary: '#6b7280', // Серый текст
},
};
// 2. Определение цветов для Темной темы
const darkPalette = {
primary: {
main: '#3b82f6', // Чуть светлее синий для контраста на темном
light: '#60a5fa',
dark: '#1d4ed8',
},
secondary: {
main: '#8b5cf6',
},
background: {
default: '#0B0F19', // Глубокий темный (Deep Space), лучше чем #121212
paper: '#111827', // Чуть светлее фона (Gray 900)
},
text: {
primary: '#f9fafb', // Почти белый
secondary: '#9ca3af', // Светло-серый
},
};
// 3. Функция генерации настроек
export const getDesignTokens = (mode: PaletteMode) => ({
palette: {
mode,
...(mode === 'light' ? lightPalette : darkPalette),
},
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
h1: { fontWeight: 700 },
h2: { fontWeight: 700 },
h3: { fontWeight: 600 },
h4: { fontWeight: 600 },
h5: { fontWeight: 600 },
h6: { fontWeight: 600 },
button: {
textTransform: 'none' as const, // Убираем CAPS LOCK на кнопках
fontWeight: 600,
},
},
shape: {
borderRadius: 12, // Скругляем углы у всего (кнопки, карты)
},
components: {
// Кастомизация глобальных стилей (скроллбар)
MuiCssBaseline: {
styleOverrides: {
body: {
scrollbarColor: mode === 'dark' ? '#374151 #111827' : '#d1d5db #f3f4f6',
'&::-webkit-scrollbar, & *::-webkit-scrollbar': {
backgroundColor: 'transparent',
width: '8px',
height: '8px',
},
'&::-webkit-scrollbar-thumb, & *::-webkit-scrollbar-thumb': {
borderRadius: 8,
backgroundColor: mode === 'dark' ? '#374151' : '#d1d5db',
minHeight: 24,
border: '2px solid transparent',
backgroundClip: 'content-box',
},
'&::-webkit-scrollbar-thumb:focus, & *::-webkit-scrollbar-thumb:focus': {
backgroundColor: mode === 'dark' ? '#4b5563' : '#9ca3af',
},
},
},
},
// Кастомизация Кнопок
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
boxShadow: 'none',
'&:hover': {
boxShadow: 'none',
},
},
containedPrimary: {
'&:hover': {
backgroundColor: mode === 'dark' ? '#2563eb' : '#1d4ed8',
},
},
},
},
// Кастомизация Карточек (Paper)
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none', // Убираем осветление в темной теме (стандарт Material)
},
elevation1: {
boxShadow: mode === 'light'
? '0px 2px 4px -1px rgba(0,0,0,0.05), 0px 4px 6px -1px rgba(0,0,0,0.05)'
: '0px 2px 4px -1px rgba(0,0,0,0.2), 0px 4px 6px -1px rgba(0,0,0,0.2)',
border: mode === 'light' ? '1px solid #e5e7eb' : '1px solid #374151',
},
},
},
// Кастомизация Инпутов
MuiOutlinedInput: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: mode === 'light' ? '#e5e7eb' : '#374151',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: mode === 'light' ? '#9ca3af' : '#6b7280',
},
},
},
},
// Кастомизация AppBar (Хедера)
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: mode === 'light' ? 'rgba(255, 255, 255, 0.8)' : 'rgba(17, 24, 39, 0.8)',
backdropFilter: 'blur(8px)', // Эффект стекла
borderBottom: `1px solid ${mode === 'light' ? '#e5e7eb' : '#374151'}`,
boxShadow: 'none',
color: mode === 'light' ? '#111827' : '#f9fafb',
},
},
},
MuiTableRow: {
root: {
"&:last-child td": {
borderBottom: 0,
},
}
}
},
});
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src", "../server/src/auth"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
open: true
}
})
+16
View File
@@ -0,0 +1,16 @@
services:
postgres:
image: postgres:18-alpine
container_name: 3dp-postgres
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-admin}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin}
POSTGRES_DB: ${POSTGRES_DB:-3dp_manager}
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
volumes:
pg_data:
+1 -301
View File
@@ -36,305 +36,10 @@ echo " 3DP-MANAGER SUBSCRIPTION FORWARDER "
echo "==================================================="
echo ""
#################################
# INPUT
#################################
read -rp "URL исходной подписки: " ORIGIN_SUB_URL
[[ "$ORIGIN_SUB_URL" =~ ^https?:// ]] || die "Некорректный URL подписки"
read -rp "Домен или IP этого сервера (Enter = авто IP): " LOCAL_HOST
if [[ -z "$LOCAL_HOST" ]]; then
LOCAL_HOST=$(hostname -I | awk '{print $1}' | tr -d '[:space:]')
fi
#################################
# PARSE URL
#################################
ORIGIN_PROTO=$(echo "$ORIGIN_SUB_URL" | awk -F: '{print $1}')
ORIGIN_HOST=$(echo "$ORIGIN_SUB_URL" | awk -F[/:] '{print $4}')
ORIGIN_PORT=$(echo "$ORIGIN_SUB_URL" | awk -F[:] '{print $3}' | awk -F/ '{print $1}')
SUB_PATH="/$(echo "$ORIGIN_SUB_URL" | cut -d/ -f4-)"
#################################
# Определяем ORIGIN_IP
#################################
if [[ "$ORIGIN_HOST" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
ORIGIN_IP="$ORIGIN_HOST"
log "Используем прямой IP из URL: $ORIGIN_IP"
else
log "Разрешаем домен $ORIGIN_HOST в IP..."
ORIGIN_IP=$(getent ahosts "$ORIGIN_HOST" | awk '/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ {print $1; exit}')
[[ -z "$ORIGIN_IP" ]] && ORIGIN_IP=$(getent hosts "$ORIGIN_HOST" | awk '{print $1; exit}')
if [[ -z "$ORIGIN_IP" ]] && command -v dig >/dev/null; then
ORIGIN_IP=$(dig +short "$ORIGIN_HOST" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1)
fi
[[ -n "$ORIGIN_IP" ]] || die "Не удалось разрешить домен в IP: $ORIGIN_HOST. Проверьте DNS или укажите IP вручную."
log "Домен $ORIGIN_HOST разрешён в IP: $ORIGIN_IP"
fi
[[ -n "$ORIGIN_IP" && -n "$ORIGIN_PORT" && -n "$SUB_PATH" ]] || die "Ошибка парсинга URL подписки"
#################################
# HTTPS CHECK
#################################
USE_HTTPS=false
CERT_PATH=""
KEY_PATH=""
if [[ "$LOCAL_HOST" != "$(hostname -I | awk '{print $1}' | tr -d '[:space:]')" ]]; then
if [[ -d "/etc/letsencrypt/live/$LOCAL_HOST" ]]; then
CERT_PATH="/etc/letsencrypt/live/$LOCAL_HOST/fullchain.pem"
KEY_PATH="/etc/letsencrypt/live/$LOCAL_HOST/privkey.pem"
USE_HTTPS=true
log "Найдены сертификаты Let's Encrypt"
else
warn "Сертификаты Let's Encrypt не найдены"
read -rp "Путь к fullchain.pem (Enter = HTTP): " CERT_PATH
if [[ -n "$CERT_PATH" ]]; then
read -rp "Путь к privkey.pem: " KEY_PATH
[[ -f "$CERT_PATH" && -f "$KEY_PATH" ]] || die "Файлы сертификатов не найдены"
USE_HTTPS=true
fi
fi
fi
#################################
# PROJECT DIR
#################################
BASE_DIR="/opt/3dp-manager"
NODE_DIR="$BASE_DIR/node"
mkdir -p "$NODE_DIR"
cd "$BASE_DIR"
#################################
# DOWNLOAD FILES
#################################
REPO="https://raw.githubusercontent.com/denpiligrim/3dp-manager/main"
NGINX_PORT=$ORIGIN_PORT
if [[ "$USE_HTTPS" == "true" ]]; then
NGINX_PROTO="https"
else
NGINX_PROTO="http"
fi
SUB_URL="$NGINX_PROTO://$LOCAL_HOST:$NGINX_PORT$SUB_PATH"
#################################
# ENV
#################################
cat > .env <<EOF
ORIGIN_SUB_URL=$ORIGIN_SUB_URL
ORIGIN_HOST=$ORIGIN_HOST
ORIGIN_PORT=$ORIGIN_PORT
LOCAL_HOST=$LOCAL_HOST
SUB_URL=$SUB_URL
EOF
#################################
# node files
#################################
cat > node/package.json <<EOF
{
"type": "module",
"dependencies": {
"axios": "^1.13.2",
"express": "^5.2.1"
}
}
EOF
cat > node/Dockerfile <<EOF
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install --production
COPY index.js .
CMD ["node", "index.js"]
EOF
cat > node/index.js <<'EOF'
import express from "express";
import axios from "axios";
import https from "https";
const app = express();
const {
ORIGIN_SUB_URL,
LOCAL_HOST
} = process.env;
const agent = new https.Agent({
rejectUnauthorized: false
});
app.get("/bus/:token", async (req, res) => {
try {
const url = ORIGIN_SUB_URL;
const r = await axios.get(url, { timeout: 15000, httpsAgent: agent });
let data = r.data;
// vmess base64
const lines = data.split("\n").map(l => {
if (l.startsWith("vmess://")) {
const obj = JSON.parse(Buffer.from(l.slice(8), "base64").toString());
obj.add = LOCAL_HOST;
return "vmess://" + Buffer.from(JSON.stringify(obj)).toString("base64");
}
// остальные протоколы — замена хоста
return l.replace(/@([^:/?#]+)/, `@${LOCAL_HOST}`);
});
res.type("text/plain").send(lines.join("\n"));
} catch (e) {
res.status(500).send("subscription error");
}
});
app.listen(3000, () => {
console.log("sub-forwarder started");
});
EOF
#################################
# NGINX & DOCKER COMPOSE
#################################
if $USE_HTTPS; then
cat > nginx.conf <<EOF
events {}
http {
server {
listen $NGINX_PORT ssl;
server_name $LOCAL_HOST;
ssl_certificate $CERT_PATH;
ssl_certificate_key $KEY_PATH;
location $SUB_PATH {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
add_header Subscription-Userinfo "upload=0; download=0; total=109951162777600; expire=0" always;
add_header Access-Control-Allow-Origin *;
}
}
}
EOF
cat > docker-compose.yml <<EOF
services:
node:
build: ./node
env_file: .env
restart: unless-stopped
network_mode: host
container_name: node
nginx:
image: nginx:alpine
restart: unless-stopped
container_name: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- $CERT_PATH:$CERT_PATH:ro
- $KEY_PATH:$KEY_PATH:ro
network_mode: host
depends_on:
- node
EOF
else
cat > nginx.conf <<EOF
events {}
http {
server {
listen $NGINX_PORT;
server_name $LOCAL_HOST;
location $SUB_PATH {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
add_header Subscription-Userinfo "upload=0; download=0; total=109951162777600; expire=0" always;
add_header Access-Control-Allow-Origin *;
}
}
}
EOF
cat > docker-compose.yml <<EOF
services:
node:
build: ./node
env_file: .env
restart: unless-stopped
network_mode: host
container_name: node
nginx:
image: nginx:alpine
restart: unless-stopped
container_name: nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
network_mode: host
depends_on:
- node
EOF
fi
#################################
# DOCKER
#################################
log "Проверка Docker"
if command -v docker >/dev/null 2>&1; then
log "Docker уже установлен"
else
log "Docker не найден, будет установлен из официального репозитория"
# Add Docker's official GPG key:
apt update
apt install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
if [[ "$ID" == "ubuntu" ]]; then
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc || die "Ошибка добавления ключа Docker"
else
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc || die "Ошибка добавления ключа Docker"
fi
chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository to Apt sources:
CODENAME=${UBUNTU_CODENAME:-$VERSION_CODENAME}
tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/$ID
Suites: $CODENAME
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt update
apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker
systemctl start docker
fi
#################################
# START
#################################
docker compose up -d --build
ORIGIN_IP=${ORIGIN_IP:-false}
#################################
# UFW NAT
@@ -389,8 +94,6 @@ cat <<EOF > /tmp/ufw_nat_rules
*nat
:PREROUTING ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
# Исключаем порт Nginx менеджера
-A PREROUTING -p tcp --dport $NGINX_PORT -j RETURN
# Проброс портов
-A PREROUTING -p tcp -m multiport --dports 443,8443,10000:60000 -j DNAT --to-destination $ORIGIN_IP
-A PREROUTING -p udp -m multiport --dports 443,8443,10000:60000 -j DNAT --to-destination $ORIGIN_IP
@@ -431,7 +134,6 @@ ufw allow 443/tcp
ufw allow 443/udp
ufw allow 8443/tcp
ufw allow 8443/udp
ufw allow "$NGINX_PORT"/tcp
ufw allow 10000:60000/tcp
ufw allow 10000:60000/udp
@@ -447,5 +149,3 @@ echo "Готово! Система оптимизирована, порты от
#################################
echo
log "Готово"
echo "Подписка:"
echo "$SUB_URL"
+56
View File
@@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+11153
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
{
"name": "server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.0",
"@nestjs/typeorm": "^11.0.0",
"axios": "^1.13.2",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.17.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"ssh2": "^1.17.0",
"typeorm": "^0.3.28",
"uuid": "^13.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.6",
"@types/ssh2": "^1.15.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
+58
View File
@@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { Setting } from './settings/entities/setting.entity';
import { Domain } from './domains/entities/domain.entity';
import { Subscription } from './subscriptions/entities/subscription.entity';
import { Inbound } from './inbounds/entities/inbound.entity';
import { XuiModule } from './xui/xui.module';
import { InboundsModule } from './inbounds/inbounds.module';
import { RotationModule } from './rotation/rotation.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { DomainsModule } from './domains/domains.module';
import { SettingsModule } from './settings/settings.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from './auth/auth.module';
import { ClientModule } from './client/client.module';
import { TunnelsModule } from './tunnels/tunnels.module';
import { Tunnel } from './tunnels/entities/tunnel.entity';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [Setting, Domain, Subscription, Inbound, Tunnel],
synchronize: true,
}),
XuiModule,
InboundsModule,
RotationModule,
SubscriptionsModule,
DomainsModule,
SettingsModule,
AuthModule,
ClientModule,
TunnelsModule
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule { }
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Controller, Post, Body, Request, UseGuards, Get } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@Post('login')
async login(@Body() req) {
const user = await this.authService.validateUser(req.login, req.password);
if (!user) {
throw new Error('Invalid credentials');
}
return this.authService.login(user);
}
@Post('change-password')
async changePassword(@Body('password') password: string) {
await this.authService.changePassword(password);
return { success: true };
}
@Post('update-profile')
async updateProfile(@Body() body: { login: string; password?: string }) {
await this.authService.updateAdminProfile(body.login, body.password);
return { success: true };
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Setting } from '../settings/entities/setting.entity';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
TypeOrmModule.forFeature([Setting]),
PassportModule,
JwtModule.register({
secret: 'SECRET_KEY_CHANGE_ME',
signOptions: { expiresIn: '24h' },
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
+110
View File
@@ -0,0 +1,110 @@
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { Setting } from '../settings/entities/setting.entity';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
private jwtService: JwtService,
) {}
async validateUser(login: string, pass: string): Promise<any> {
this.logger.log(`Попытка входа с логином: ${login}`);
const dbLogin = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
const dbPass = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
if (!dbLogin) {
this.logger.error('Пользователь admin_login не найден в базе данных!');
return null;
}
if (!dbPass) {
this.logger.error('Пароль admin_password не найден в базе данных!');
return null;
}
this.logger.log(`Пользователь найден, проверяем хеш пароля...`);
// Сравниваем пароль
const isMatch = await bcrypt.compare(pass, dbPass.value);
if (isMatch) {
this.logger.log('Пароль верный!');
return { login: dbLogin.value };
} else {
this.logger.warn('Пароль неверный.');
return null;
}
}
async login(user: any) {
const payload = { username: user.login };
return {
access_token: this.jwtService.sign(payload),
};
}
async changePassword(newPass: string) {
const hash = await bcrypt.hash(newPass, 10);
let setting = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
if (!setting) {
setting = this.settingsRepo.create({ key: 'admin_password' });
}
setting.value = hash;
await this.settingsRepo.save(setting);
this.logger.log('Пароль администратора изменен.');
}
// ... imports
// (Оставьте существующие методы без изменений, добавьте/обновите этот)
async updateAdminProfile(login: string, password?: string) {
let loginSetting = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
if (!loginSetting) loginSetting = this.settingsRepo.create({ key: 'admin_login' });
loginSetting.value = login;
await this.settingsRepo.save(loginSetting);
if (password && password.trim().length > 0) {
const hash = await bcrypt.hash(password, 10);
let passSetting = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
if (!passSetting) passSetting = this.settingsRepo.create({ key: 'admin_password' });
passSetting.value = hash;
await this.settingsRepo.save(passSetting);
}
this.logger.log(`Профиль администратора обновлен. Новый логин: ${login}`);
}
// Обновленный метод инициализации
async seedAdmin() {
const login = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
// Если пользователя нет ИЛИ если нужно принудительно сбросить (для отладки)
if (!login) {
this.logger.log('Инициализация администратора (admin / admin)...');
// 1. Сохраняем логин
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: 'admin' });
await this.settingsRepo.save(loginSetting);
// 2. Сохраняем пароль
const hash = await bcrypt.hash('admin', 10);
const passSetting = this.settingsRepo.create({ key: 'admin_password', value: hash });
await this.settingsRepo.save(passSetting);
this.logger.log('Администратор успешно создан.');
} else {
this.logger.log('Администратор уже существует в базе.');
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: 'SECRET_KEY_CHANGE_ME',
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
}
}
+2
View File
@@ -0,0 +1,2 @@
import { SetMetadata } from '@nestjs/common';
export const Public = () => SetMetadata('isPublic', true);
+133
View File
@@ -0,0 +1,133 @@
import { Controller, Get, Param, HttpException, HttpStatus, Res, Req, Inject } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { Response, Request } from 'express';
import * as QRCode from 'qrcode';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Public } from '../auth/public.decorator';
@Controller() // Убираем 'client', так как путь зададим явно
export class ClientController {
constructor(
@InjectRepository(Subscription)
private subRepo: Repository<Subscription>,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) { }
@Public()
@Get('bus/:uuid') // Тот самый путь /bus/UUID
async getSubscription(
@Param('uuid') uuid: string,
@Req() req: Request,
@Res() res: Response
) {
// 1. Ищем подписку
const sub = await this.subRepo.findOne({
where: { uuid },
relations: ['inbounds']
});
if (!sub || !sub.isEnabled) {
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
}
// 2. Генерируем список ссылок (Config)
const links = sub.inbounds
?.map(i => i.link)
.filter(l => l && l.length > 0) || [];
// Формируем Base64 строку (это и есть подписка для клиента)
const plainTextList = links.join('\n');
const base64Config = Buffer.from(plainTextList).toString('base64');
const userAgent = req.headers['user-agent'] || '';
const isBrowser = /Mozilla|Chrome|Safari|Firefox|Edge/.test(userAgent);
if (!isBrowser) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(base64Config);
} else {
const currentUrl = `${req.protocol}://${req.get('host')}/bus/${uuid}`;
const cacheKey = `qr_${uuid}`;
let qrDataUrl = await this.cacheManager.get<string>(cacheKey);
if (!qrDataUrl) {
qrDataUrl = await QRCode.toDataURL(currentUrl, { width: 300, margin: 2 });
await this.cacheManager.set(cacheKey, qrDataUrl, 86400000);
} else {
console.log(`Взяли QR из кэша для ${uuid}`);
}
// HTML шаблон
const html = `
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${sub.name} | 3DP-MANAGER</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f4f6f8; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: white; padding: 2rem; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 90%; }
h2 { margin-top: 0; color: #333; }
.qr-box { background: #fff; padding: 10px; border: 1px solid #eee; border-radius: 8px; display: inline-block; margin: 20px 0; }
.link-box { background: #f5f5f5; padding: 10px; border-radius: 6px; font-family: monospace; word-break: break-all; font-size: 12px; color: #666; margin-bottom: 20px; border: 1px solid #e0e0e0; }
button { background-color: #1976d2; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 16px; cursor: pointer; transition: background 0.2s; width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; }
button:hover { background-color: #1565c0; }
button:active { transform: scale(0.98); }
.note { margin-top: 20px; font-size: 12px; color: #999; }
#subscription-links { display: none; }
</style>
</head>
<body>
<div class="card">
<h2>Ваша подписка</h2>
<p style="color: #666;">Отсканируйте QR-код в приложении Happ, v2RayTun или Streisand</p>
<div class="qr-box">
<img src="${qrDataUrl}" alt="QR Code" />
</div>
<div class="link-box" id="link-text">${currentUrl}</div>
<button onclick="copyLink()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"><path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/></svg>
Копировать ссылку
</button>
<div class="note">Для автоматического обновления конфигов используйте эту ссылку</div>
</div>
<textarea id="subscription-links">${base64Config}</textarea>
<script>
function copyLink() {
const link = document.getElementById('link-text').innerText;
navigator.clipboard.writeText(link).then(() => {
const btn = document.querySelector('button');
const originalText = btn.innerHTML;
btn.innerHTML = 'Скопировано!';
btn.style.backgroundColor = '#2e7d32';
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.backgroundColor = '#1976d2';
}, 2000);
});
}
</script>
</body>
</html>
`;
res.setHeader('Content-Type', 'text/html');
res.send(html);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClientController } from './client.controller';
import { CacheModule } from '@nestjs/cache-manager';
import { Subscription } from '../subscriptions/entities/subscription.entity';
@Module({
imports: [TypeOrmModule.forFeature([Subscription]), CacheModule.register()],
controllers: [ClientController],
})
export class ClientModule {}
+46
View File
@@ -0,0 +1,46 @@
import { Controller, Get, Post, Body, Param, Delete, Query } from '@nestjs/common';
import { DomainsService } from './domains.service';
@Controller('domains')
export class DomainsController {
constructor(private readonly domainsService: DomainsService) { }
@Post()
create(@Body() body: { name: string }) {
return this.domainsService.create(body);
}
// Загрузка списка (массива строк)
@Post('upload')
uploadMany(@Body() body: { domains: string[] }) {
return this.domainsService.createMany(body.domains);
}
@Get()
findAll(
@Query('page') page: number,
@Query('limit') limit: number
) {
// Если параметры не передали, ставим дефолтные: стр 1, лимит 10
const pageNum = page ? +page : 1;
const limitNum = limit ? +limit : 10;
return this.domainsService.findAll(pageNum, limitNum);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.domainsService.findOne(+id);
}
// ВАЖНО: @Delete('all') должен идти ПЕРЕД @Delete(':id')
@Delete('all')
removeAll() {
return this.domainsService.removeAll();
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.domainsService.remove(+id);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DomainsService } from './domains.service';
import { DomainsController } from './domains.controller';
import { Domain } from './entities/domain.entity';
@Module({
imports: [TypeOrmModule.forFeature([Domain])],
controllers: [DomainsController],
providers: [DomainsService],
exports: [DomainsService],
})
export class DomainsModule {}
+80
View File
@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Domain } from './entities/domain.entity';
@Injectable()
export class DomainsService {
constructor(
@InjectRepository(Domain)
private repo: Repository<Domain>,
) { }
// Создать один домен
async create(createDomainDto: { name: string }) {
// Простейшая проверка на дубликат (можно и через try-catch)
const exists = await this.repo.findOne({ where: { name: createDomainDto.name } });
if (exists) return exists;
const domain = this.repo.create(createDomainDto);
return this.repo.save(domain);
}
async findAll(page: number = 1, limit: number = 10) {
const skip = (page - 1) * limit;
const [result, total] = await this.repo.findAndCount({
take: limit, // Сколько взять (10)
skip: skip, // Сколько пропустить
order: { id: 'DESC' }, // Сортируем: новые сверху
});
return {
data: result,
total: total,
};
}
findOne(id: number) {
return this.repo.findOneBy({ id });
}
// Удалить один
remove(id: number) {
return this.repo.delete(id);
}
// === СПЕЦИАЛЬНЫЕ МЕТОДЫ ===
// 1. Удалить вообще всё (для кнопки "Удалить все")
async removeAll() {
await this.repo.clear(); // TRUNCATE table
return { success: true };
}
// 2. Массовая загрузка из файла
async createMany(names: string[]) {
if (!names || names.length === 0) return { count: 0 };
// Убираем пробелы и пустые строки
const cleanNames = names
.map(n => n.trim())
.filter(n => n.length > 0);
// Получаем текущие домены, чтобы не вставлять дубли
const existing = await this.repo.find();
const existingSet = new Set(existing.map(d => d.name));
// Оставляем только новые
const uniqueNewNames = [...new Set(cleanNames)] // убираем дубли внутри самого файла
.filter(name => !existingSet.has(name)); // убираем те, что уже есть в БД
if (uniqueNewNames.length === 0) return { count: 0 };
// Создаем и сохраняем
const entities = uniqueNewNames.map(name => this.repo.create({ name }));
await this.repo.save(entities);
return { count: entities.length };
}
}
@@ -0,0 +1,13 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Domain {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
name: string;
@Column({ default: true })
isEnabled: boolean;
}
@@ -0,0 +1,26 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import { Subscription } from '../../subscriptions/entities/subscription.entity';
@Entity()
export class Inbound {
@PrimaryGeneratedColumn()
id: number;
@Column()
xuiId: number;
@Column()
port: number;
@Column()
protocol: string;
@Column({ nullable: true })
remark: string;
@Column({ type: 'text', nullable: true })
link: string;
@ManyToOne(() => Subscription, (sub) => sub.inbounds, { onDelete: 'CASCADE' })
subscription: Subscription;
}
@@ -0,0 +1,342 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class InboundBuilderService {
private readonly flag = process.env.COUNTRY_FLAG ?? '%F0%9F%92%AF';
buildVlessRealityTcp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-tcp-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: 'xtls-rprx-vision', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'tcp',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
tcpSettings: { acceptProxyProtocol: false, header: { type: 'none' } }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'], metadataOnly: false, routeOnly: false })
};
}
buildVlessRealityXhttp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-xhttp-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: 'xtls-rprx-vision', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'xhttp',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
xhttpSettings: { path: '/', mode: 'auto' }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'], metadataOnly: false, routeOnly: false })
};
}
buildVlessRealityGrpc(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-grpc-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: '', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'grpc',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
grpcSettings: { serviceName: 'grpc', multiMode: false }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildVlessWs(params: { port: number; uuid: string; domain: string }) {
const { port, uuid, domain } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-ws`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: '', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'ws',
security: 'none',
externalProxy: [],
wsSettings: { path: '/', headers: { Host: domain } }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildVmessTcp(params: { port: number; uuid: string }) {
const { port, uuid } = params;
return {
enable: true,
port,
protocol: 'vmess',
remark: 'vmess-tcp',
settings: JSON.stringify({
clients: [{ id: uuid, alterId: 0, email: uuid, limitIp: 0, totalGB: 0, expiryTime: 0, enable: true, tgId: '', subId: '', reset: 0 }],
disableInsecureEncryption: false
}),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none', tcpSettings: { header: { type: 'http', request: { method: 'GET', path: ['/'], headers: { Host: [] } } } } }),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildShadowsocksTcp(params: { port: number; uuid: string }) {
const { port, uuid } = params;
return {
enable: true,
port,
protocol: 'shadowsocks',
remark: 'shadowsocks-tcp',
settings: JSON.stringify({
method: 'aes-256-gcm',
password: uuid,
network: 'tcp,udp',
clients: []
}),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildTrojanRealityTcp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'trojan',
remark: `trojan-tcp-reality`,
settings: JSON.stringify({
clients: [{ password: uuid, email: uuid, limitIp: 0, totalGB: 0, expiryTime: 0, enable: true, tgId: '', subId: '', reset: 0 }],
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'tcp',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
}
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
generateUuid() {
return uuidv4();
}
buildInboundLink(inbound: any, domain: string, idOrPass: string): string {
let link = "";
switch (inbound.protocol) {
case "vless":
link = this.buildVlessLink(inbound, domain, idOrPass);
break;
case "vmess":
link = this.buildVmessLink(inbound, domain, idOrPass);
break;
case "shadowsocks":
link = this.buildSsLink(inbound, domain, idOrPass);
break;
case "trojan":
link = this.buildTrojanLink(inbound, domain, idOrPass);
break;
}
return link;
}
private buildVlessLink(inbound: any, domain: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings);
const settings = JSON.parse(inbound.settings);
const network = stream.network;
const security = stream.security || "none";
const params = new URLSearchParams();
params.set("type", network);
params.set("encryption", "none");
params.set("security", security);
if (security === "reality") {
const r = stream.realitySettings;
params.set("pbk", r.settings.publicKey);
params.set("fp", r.settings.fingerprint || "random");
params.set("sni", r.serverNames?.[0] || "");
params.set("sid", r.shortIds?.[0] || "");
params.set("spx", '/');
if (network === "tcp") {
const client = settings.clients?.[0];
if (client?.flow) {
params.set("flow", client.flow);
}
}
if (network === "xhttp") {
const x = stream.xhttpSettings || {};
params.set("path", x.path || "/");
params.set("host", x.host || r.serverNames?.[0]);
params.set("mode", x.mode || "auto");
}
if (network === "grpc") {
const g = stream.grpcSettings || {};
params.set("serviceName", g.serviceName || "grpc");
params.set("authority", g.authority || r.serverNames?.[0]);
}
}
if (network === "ws") {
const ws = stream.wsSettings || {};
params.set("path", ws.path || "/");
if (ws.headers?.Host) {
params.set("host", ws.headers.Host);
}
}
return (
`vless://${uuid}@${domain}:${inbound.port}` +
`?${params.toString()}` +
`#${this.flag}%20${encodeURIComponent(inbound.remark)}`
);
}
private buildVmessLink(inbound: any, domain: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings);
const vmessObj = {
add: domain,
aid: '',
alpn: "",
fp: "",
host: "",
id: uuid,
net: stream.network || "tcp",
path: "/",
port: inbound.port,
ps: decodeURIComponent(this.flag) + ' ' + inbound.remark,
scy: "",
sni: "",
tls: stream.security || "none",
type: "none",
v: "2"
};
const base64 = Buffer
.from(JSON.stringify(vmessObj), "utf8")
.toString("base64");
return `vmess://${base64}`;
}
private buildSsLink(inbound: any, domain: string, idOrPass: string) {
const settings = JSON.parse(inbound.settings);
const method = settings.method;
const serverPassword = settings.password;
const finalPass = serverPassword || idOrPass;
const userInfo = `${method}:${finalPass}`;
const base64 = Buffer
.from(userInfo, "utf8")
.toString("base64");
return `ss://${base64}@${domain}:${inbound.port}?type=tcp#${this.flag}%20${inbound.remark}`;
}
private buildTrojanLink(inbound: any, domain: string, password: string) {
const stream = JSON.parse(inbound.streamSettings);
const reality = stream.realitySettings;
const pbk = reality.settings.publicKey;
const sni = reality.serverNames?.[0] || domain;
const sid = reality.shortIds?.[0] || "";
const spx = '%2F';
return (
`trojan://${password}@${domain}:${inbound.port}` +
`?type=tcp` +
`&security=reality` +
`&pbk=${pbk}` +
`&fp=random` +
`&sni=${sni}` +
`&sid=${sid}` +
`&spx=${spx}` +
`#${this.flag}%20${inbound.remark}`
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Inbound } from './entities/inbound.entity';
import { InboundBuilderService } from './inbound-builder.service';
@Module({
imports: [TypeOrmModule.forFeature([Inbound])],
providers: [InboundBuilderService],
exports: [InboundBuilderService],
})
export class InboundsModule {}
+19
View File
@@ -0,0 +1,19 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AuthService } from './auth/auth.service';
import { RequestMethod } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const authService = app.get(AuthService);
await authService.seedAdmin();
app.enableCors();
app.setGlobalPrefix('api', {
exclude: [{ path: 'bus/:uuid', method: RequestMethod.GET }]
});
await app.listen(3000);
}
bootstrap();
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { RotationService } from './rotation.service';
import { XuiModule } from '../xui/xui.module';
import { InboundsModule } from '../inbounds/inbounds.module';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { Domain } from '../domains/entities/domain.entity';
import { Setting } from '../settings/entities/setting.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Subscription, Inbound, Domain, Setting]),
ScheduleModule.forRoot(),
XuiModule,
InboundsModule,
],
providers: [RotationService],
})
export class RotationModule {}
+169
View File
@@ -0,0 +1,169 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { Domain } from '../domains/entities/domain.entity';
import { Setting } from '../settings/entities/setting.entity';
import { XuiService } from '../xui/xui.service';
import { InboundBuilderService } from '../inbounds/inbound-builder.service';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class RotationService {
private readonly logger = new Logger(RotationService.name);
constructor(
@InjectRepository(Subscription) private subRepo: Repository<Subscription>,
@InjectRepository(Inbound) private inboundRepo: Repository<Inbound>,
@InjectRepository(Domain) private domainRepo: Repository<Domain>,
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
private xuiService: XuiService,
private inboundBuilder: InboundBuilderService,
) {}
@Cron(CronExpression.EVERY_MINUTE)
async handleTicker() {
const intervalSetting = await this.settingRepo.findOne({ where: { key: 'rotation_interval' } });
const intervalMinutes = intervalSetting ? parseInt(intervalSetting.value, 10) : 30;
const lastRunSetting = await this.settingRepo.findOne({ where: { key: 'last_rotation_timestamp' } });
const lastRun = lastRunSetting ? parseInt(lastRunSetting.value, 10) : 0;
const now = Date.now();
const diffMinutes = (now - lastRun) / 1000 / 60;
if (diffMinutes < intervalMinutes) {
return;
}
await this.performRotation();
await this.saveSetting('last_rotation_timestamp', now.toString());
}
private async saveSetting(key: string, value: string) {
let s = await this.settingRepo.findOne({ where: { key } });
if (!s) s = this.settingRepo.create({ key });
s.value = value;
await this.settingRepo.save(s);
}
private async performRotation() {
this.logger.log('Запуск плановой ротации...');
const isLoginSuccess = await this.xuiService.login();
if (!isLoginSuccess) {
this.logger.error('Отмена ротации: Не удалось войти в панель 3x-ui');
return;
}
const subscriptions = await this.subRepo.find({ where: { isEnabled: true }, relations: ['inbounds'] });
if (subscriptions.length === 0) return;
const domains = await this.domainRepo.find({ where: { isEnabled: true } });
if (domains.length === 0) {
this.logger.warn('Список доменов пуст! Ротация невозможна.');
return;
}
for (const sub of subscriptions) {
await this.rotateSubscription(sub, domains);
}
this.logger.log('Ротация завершена.');
}
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
this.logger.log(`Ротация для подписки: ${sub.name} (${sub.uuid})`);
if (sub.inbounds && sub.inbounds.length > 0) {
for (const inbound of sub.inbounds) {
await this.xuiService.deleteInbound(inbound.xuiId);
await this.inboundRepo.delete(inbound.id);
}
}
const keys = await this.xuiService.getNewX25519Cert();
if (!keys) {
this.logger.error("Не удалось получить Reality ключи, пропускаем подписку");
return;
}
const usedPorts = new Set<number>();
const tasks = [
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }), // Port 8443 pref
() => this.inboundBuilder.buildVlessRealityXhttp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }), // Port 443 pref
() => this.inboundBuilder.buildVlessRealityGrpc({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessWs({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains) }),
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVmessTcp({ port: 0, uuid: uuidv4() }),
() => this.inboundBuilder.buildShadowsocksTcp({ port: 0, uuid: uuidv4() }),
() => this.inboundBuilder.buildTrojanRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
];
const host = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
const serverAddress = host?.value || 'localhost';
for (const [index, task] of tasks.entries()) {
let config = task();
let port = 0;
if (index === 0) port = await this.getFreePort(8443, usedPorts);
else if (index === 1) port = await this.getFreePort(443, usedPorts);
else port = await this.getFreePort(0, usedPorts);
config.port = port;
usedPorts.add(port);
const xuiId = await this.xuiService.addInbound(config);
if (xuiId) {
const remarkParts = config.remark.split('-');
let domainForLink = 'unknown';
try {
const ss = JSON.parse(config.streamSettings || '{}');
if (ss.realitySettings?.serverNames?.[0]) domainForLink = ss.realitySettings.serverNames[0];
else if (ss.wsSettings?.headers?.Host) domainForLink = ss.wsSettings.headers.Host;
else if (ss.tcpSettings?.header?.request?.headers?.Host?.[0]) domainForLink = ss.tcpSettings.header.request.headers.Host[0];
} catch (e) { }
const idOrPass = config.settings ? JSON.parse(config.settings).clients?.[0]?.id || JSON.parse(config.settings).clients?.[0]?.password : "";
const fullLink = this.inboundBuilder.buildInboundLink(config, serverAddress, idOrPass);
const newInbound = this.inboundRepo.create({
xuiId: xuiId,
port: port,
protocol: config.protocol,
remark: config.remark,
link: fullLink,
subscription: sub
});
await this.inboundRepo.save(newInbound);
}
}
}
private pickDomain(list: Domain[]): string {
return list[Math.floor(Math.random() * list.length)].name;
}
private async getFreePort(preferred: number, currentBatch: Set<number>): Promise<number> {
if (preferred > 0 && !currentBatch.has(preferred)) {
const exists = await this.inboundRepo.findOne({ where: { port: preferred } });
if (!exists) return preferred;
}
while (true) {
const p = Math.floor(Math.random() * (60000 - 10000)) + 10000;
if (currentBatch.has(p)) continue;
const exists = await this.inboundRepo.findOne({ where: { port: p } });
if (!exists) return p;
}
}
}
@@ -0,0 +1,13 @@
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity()
export class Setting {
@PrimaryColumn()
key: string;
@Column()
value: string;
@Column({ nullable: true })
description: string;
}
@@ -0,0 +1,40 @@
import { Controller, Get, Post, Body } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Setting } from './entities/setting.entity';
import * as dns from 'dns/promises';
@Controller('settings')
export class SettingsController {
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
) {}
@Get()
async findAll() {
const settings = await this.settingsRepo.find();
return settings.reduce((acc, curr) => ({ ...acc, [curr.key]: curr.value }), {});
}
@Post()
async update(@Body() settings: Record<string, string>) {
if (settings.xui_url) {
try {
const parsed = new URL(settings.xui_url);
settings['xui_host'] = parsed.hostname;
const { address } = await dns.lookup(parsed.hostname);
settings['xui_ip'] = address;
console.log(`Extracted host: ${parsed.hostname} from ${settings.xui_url}`);
} catch (e) {
console.warn(`Не удалось извлечь хост из URL: ${settings.xui_url}`);
}
}
for (const [key, value] of Object.entries(settings)) {
await this.settingsRepo.save({ key, value });
}
return { success: true };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SettingsController } from './settings.controller';
import { Setting } from './entities/setting.entity';
@Module({
imports: [TypeOrmModule.forFeature([Setting])],
controllers: [SettingsController],
})
export class SettingsModule {}
@@ -0,0 +1,26 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Inbound } from '../../inbounds/entities/inbound.entity';
@Entity()
export class Subscription {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ unique: true })
uuid: string;
@Column({ default: true })
isEnabled: boolean;
@OneToMany(() => Inbound, (inbound) => inbound.subscription)
inbounds: Inbound[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,22 @@
import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
@Controller('subscriptions')
export class SubscriptionsController {
constructor(private readonly subscriptionsService: SubscriptionsService) {}
@Get()
findAll() {
return this.subscriptionsService.findAll();
}
@Post()
create(@Body('name') name: string) {
return this.subscriptionsService.create(name);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.subscriptionsService.remove(id);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SubscriptionsService } from './subscriptions.service';
import { SubscriptionsController } from './subscriptions.controller';
import { Subscription } from './entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { XuiModule } from '../xui/xui.module';
@Module({
imports: [TypeOrmModule.forFeature([Subscription, Inbound]), XuiModule],
controllers: [SubscriptionsController],
providers: [SubscriptionsService],
exports: [SubscriptionsService],
})
export class SubscriptionsModule {}
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Subscription } from './entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { XuiService } from '../xui/xui.service';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class SubscriptionsService {
constructor(
@InjectRepository(Subscription)
private subRepo: Repository<Subscription>,
@InjectRepository(Inbound)
private inboundRepo: Repository<Inbound>,
private xuiService: XuiService,
) {}
findAll() {
return this.subRepo.find({ relations: ['inbounds'], order: { createdAt: 'DESC' } });
}
async create(name: string) {
const sub = this.subRepo.create({
name,
uuid: uuidv4(),
});
return this.subRepo.save(sub);
}
async remove(id: string) {
const sub = await this.subRepo.findOne({ where: { id }, relations: ['inbounds'] });
if (!sub) return;
if (sub.inbounds) {
for (const inbound of sub.inbounds) {
await this.xuiService.deleteInbound(inbound.xuiId);
}
}
return this.subRepo.remove(sub);
}
}
@@ -0,0 +1,28 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Tunnel {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
ip: string;
@Column({ default: 22 })
sshPort: number;
@Column()
username: string;
@Column({ select: false })
password: string;
@Column({ nullable: true })
domain: string;
@Column({ default: false })
isInstalled: boolean;
}
+49
View File
@@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import { Client } from 'ssh2';
@Injectable()
export class SshService {
private readonly logger = new Logger(SshService.name);
async executeCommand(
config: { host: string; port: number; username: string; password?: string },
command: string
): Promise<string> {
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => {
this.logger.log(`SSH Connection established to ${config.host}`);
conn.exec(command, (err, stream) => {
if (err) {
conn.end();
return reject(err);
}
let output = '';
stream.on('close', (code, signal) => {
this.logger.log(`SSH Command finished with code ${code}`);
conn.end();
if (code === 0) resolve(output);
else reject(new Error(`Exit code ${code}. Output: ${output}`));
}).on('data', (data) => {
output += data.toString();
}).stderr.on('data', (data) => {
output += data.toString();
});
});
}).on('error', (err) => {
this.logger.error(`SSH Error: ${err.message}`);
reject(err);
}).connect({
host: config.host,
port: config.port,
username: config.username,
password: config.password,
readyTimeout: 20000,
});
});
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common';
import { TunnelsService } from './tunnels.service';
import { Tunnel } from './entities/tunnel.entity';
@Controller('tunnels')
export class TunnelsController {
constructor(private readonly tunnelsService: TunnelsService) {}
@Post()
create(@Body() createTunnelDto: Tunnel) {
return this.tunnelsService.create(createTunnelDto);
}
@Get()
findAll() {
return this.tunnelsService.findAll();
}
@Post(':id/install')
install(@Param('id') id: string) {
return this.tunnelsService.installScript(+id);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.tunnelsService.remove(+id);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TunnelsService } from './tunnels.service';
import { TunnelsController } from './tunnels.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tunnel } from './entities/tunnel.entity';
import { Setting } from '../settings/entities/setting.entity';
import { SshService } from './ssh.service';
@Module({
imports: [TypeOrmModule.forFeature([Tunnel, Setting])], // Setting нужен для xui_host
controllers: [TunnelsController],
providers: [TunnelsService, SshService],
})
export class TunnelsModule {}
+79
View File
@@ -0,0 +1,79 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tunnel } from './entities/tunnel.entity';
import { SshService } from './ssh.service';
import { Setting } from '../settings/entities/setting.entity';
@Injectable()
export class TunnelsService {
private readonly logger = new Logger(TunnelsService.name);
constructor(
@InjectRepository(Tunnel) private tunnelRepo: Repository<Tunnel>,
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
private sshService: SshService,
) {}
async create(createTunnelDto: any) {
const tunnel = this.tunnelRepo.create(createTunnelDto);
return this.tunnelRepo.save(tunnel);
}
async findAll() {
return this.tunnelRepo.find();
}
async remove(id: number) {
return this.tunnelRepo.delete(id);
}
// === УСТАНОВКА СКРИПТА ===
async installScript(id: number) {
// 1. Ищем туннель с паролем
const tunnel = await this.tunnelRepo.createQueryBuilder('tunnel')
.addSelect('tunnel.password')
.where('tunnel.id = :id', { id })
.getOne();
if (!tunnel) throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND);
// 2. Ищем IP основного сервера (куда пересылать трафик)
const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
if (!hostSetting || !hostSetting.value) {
throw new HttpException(
'В настройках (Settings) не сохранен Host/IP основного сервера (xui_host). Сохраните настройки подключения к 3x-ui заново.',
HttpStatus.BAD_REQUEST
);
}
const mainServerIp = hostSetting.value;
this.logger.log(`Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`);
// 3. Формируем команду
// export ORIGIN_IP="1.2.3.4" && bash <(curl ...)
const command = `export ORIGIN_IP="${mainServerIp}" && bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)`;
try {
// 4. Выполняем через SSH
const output = await this.sshService.executeCommand({
host: tunnel.ip,
port: tunnel.sshPort,
username: tunnel.username,
password: tunnel.password
}, command);
this.logger.log(`Скрипт выполнен успешно:\n${output}`);
// Помечаем как установленный
tunnel.isInstalled = true;
await this.tunnelRepo.save(tunnel);
return { success: true, output };
} catch (e) {
this.logger.error(`Ошибка SSH: ${e.message}`);
throw new HttpException(`Ошибка установки: ${e.message}`, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { XuiService } from './xui.service';
import { Setting } from '../settings/entities/setting.entity';
@Module({
imports: [TypeOrmModule.forFeature([Setting])],
providers: [XuiService],
exports: [XuiService],
})
export class XuiModule {}
+104
View File
@@ -0,0 +1,104 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import axios, { AxiosInstance } from 'axios';
import * as https from 'https';
import { Setting } from '../settings/entities/setting.entity';
@Injectable()
export class XuiService {
private readonly logger = new Logger(XuiService.name);
private api: AxiosInstance;
private cookie: string | null = null;
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
) {
this.api = axios.create({
timeout: 15000,
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
withCredentials: true,
});
this.api.interceptors.request.use((config) => {
if (this.cookie) {
config.headers['Cookie'] = this.cookie;
}
return config;
});
}
private async getSettings() {
const settings = await this.settingsRepo.find();
const config: Record<string, string> = {};
settings.forEach((s) => (config[s.key] = s.value));
return config;
}
async login() {
try {
const config = await this.getSettings();
if (!config['xui_url'] || !config['xui_login'] || !config['xui_password']) {
this.logger.warn('Настройки 3x-ui не заполнены в БД');
return false;
}
this.api.defaults.baseURL = config['xui_url'];
const res = await this.api.post('/login', {
username: config['xui_login'],
password: config['xui_password'],
});
if (res.headers['set-cookie']) {
this.cookie = res.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
this.logger.log('Успешная авторизация в 3x-ui');
return true;
}
} catch (e) {
this.logger.error(`Ошибка авторизации: ${e.message}`);
}
return false;
}
async addInbound(inboundConfig: any) {
try {
const res = await this.api.post('/panel/api/inbounds/add', inboundConfig);
if (res.data?.success) {
this.logger.log(res.data?.msg);
return res.data.obj.id;
} else {
this.logger.error(res.data?.msg);
}
} catch (e) {
this.logger.error(`Ошибка добавления инбаунда: ${e.message}`);
if (e.response?.status === 401) {
this.logger.log('Сессия истекла, пробуем релогин...');
if (await this.login()) {
return this.addInbound(inboundConfig);
}
}
}
return null;
}
async deleteInbound(id: number) {
try {
await this.api.post(`/panel/api/inbounds/del/${id}`);
this.logger.log(`Инбаунд ${id} удален`);
} catch (e) {
this.logger.error(`Ошибка удаления инбаунда ${id}: ${e.message}`);
}
}
async getNewX25519Cert() {
try {
const res = await this.api.get('/panel/api/server/getNewX25519Cert');
if (res.data?.success) return res.data.obj;
} catch (e) {
this.logger.error('Ошибка получения ключей Reality');
}
return null;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

Some files were not shown because too many files have changed in this diff Show More