gui version
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"node-cron": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 707 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:3000/api',
|
||||
});
|
||||
|
||||
export default api;
|
||||
@@ -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 |
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
open: true
|
||||
}
|
||||
})
|
||||
@@ -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:
|
||||
@@ -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
|
||||
|
||||
@@ -446,6 +148,4 @@ echo "Готово! Система оптимизирована, порты от
|
||||
# RESULT
|
||||
#################################
|
||||
echo
|
||||
log "Готово"
|
||||
echo "Подписка:"
|
||||
echo "$SUB_URL"
|
||||
log "Готово"
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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).
|
||||
@@ -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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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('Администратор уже существует в базе.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||