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 "==================================================="
|
||||||
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
|
# Определяем 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_IP:-false}
|
||||||
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
|
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# UFW NAT
|
# UFW NAT
|
||||||
@@ -389,8 +94,6 @@ cat <<EOF > /tmp/ufw_nat_rules
|
|||||||
*nat
|
*nat
|
||||||
:PREROUTING ACCEPT [0:0]
|
:PREROUTING ACCEPT [0:0]
|
||||||
:POSTROUTING 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 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
|
-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 443/udp
|
||||||
ufw allow 8443/tcp
|
ufw allow 8443/tcp
|
||||||
ufw allow 8443/udp
|
ufw allow 8443/udp
|
||||||
ufw allow "$NGINX_PORT"/tcp
|
|
||||||
ufw allow 10000:60000/tcp
|
ufw allow 10000:60000/tcp
|
||||||
ufw allow 10000:60000/udp
|
ufw allow 10000:60000/udp
|
||||||
|
|
||||||
@@ -447,5 +149,3 @@ echo "Готово! Система оптимизирована, порты от
|
|||||||
#################################
|
#################################
|
||||||
echo
|
echo
|
||||||
log "Готово"
|
log "Готово"
|
||||||
echo "Подписка:"
|
|
||||||
echo "$SUB_URL"
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||