Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f210d3ef15 | |||
| 842e4283c1 | |||
| a28d7a6954 | |||
| 009506f915 | |||
| e84d77f248 | |||
| f52e035d8a | |||
| 927a04f24a | |||
| 600b398a5a |
@@ -1,3 +1,4 @@
|
|||||||
checker/
|
checker/
|
||||||
client/.env
|
client/.env
|
||||||
client/coverage/
|
client/coverage/
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
|
||||||
|
|
||||||
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
  [](https://www.gnu.org/licenses/gpl-3.0) [](https://t.me/denpiligrim_web) [](https://www.youtube.com/@denpiligrim)
|
||||||
|
|
||||||
# 3DP-MANAGER
|
# 3DP-MANAGER
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
**Поддержать проект**
|
**Поддержать проект**
|
||||||
|
|
||||||
- Банковским переводом:
|
- Банковским переводом:
|
||||||
- :credit_card: Карта МИР: `2204320436318077`
|
- :credit_card: Карта МИР: [finance.ozon.ru](https://finance.ozon.ru/apps/sbp/ozonbankpay/019e46e5-c08d-734a-a056-6b340969760d)
|
||||||
- :credit_card: Карта MasterCard: `5395452209474530`
|
- :credit_card: Карта MasterCard: `5395452209474530`
|
||||||
- На электронный кошелек:
|
- На электронный кошелек:
|
||||||
- :moneybag: ЮМоney: `4100116897060652`
|
- :moneybag: ЮМоney: `4100116897060652`
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ services:
|
|||||||
DB_USERNAME: ${POSTGRES_USER}
|
DB_USERNAME: ${POSTGRES_USER}
|
||||||
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
DB_NAME: ${POSTGRES_DB}
|
DB_NAME: ${POSTGRES_DB}
|
||||||
|
DB_SYNCHRONIZE: "true"
|
||||||
|
DB_MIGRATIONS_RUN: "false"
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
ADMIN_LOGIN: ${ADMIN_LOGIN}
|
ADMIN_LOGIN: ${ADMIN_LOGIN}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
|||||||
@@ -469,6 +469,8 @@ services:
|
|||||||
DB_USERNAME: admin
|
DB_USERNAME: admin
|
||||||
DB_PASSWORD: ${DB_PASS}
|
DB_PASSWORD: ${DB_PASS}
|
||||||
DB_NAME: 3dp_manager
|
DB_NAME: 3dp_manager
|
||||||
|
DB_SYNCHRONIZE: "true"
|
||||||
|
DB_MIGRATIONS_RUN: "false"
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
ADMIN_LOGIN: ${ADMIN_USER}
|
ADMIN_LOGIN: ${ADMIN_USER}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||||
@@ -580,6 +582,8 @@ services:
|
|||||||
DB_USERNAME: admin
|
DB_USERNAME: admin
|
||||||
DB_PASSWORD: ${DB_PASS}
|
DB_PASSWORD: ${DB_PASS}
|
||||||
DB_NAME: 3dp_manager
|
DB_NAME: 3dp_manager
|
||||||
|
DB_SYNCHRONIZE: "true"
|
||||||
|
DB_MIGRATIONS_RUN: "false"
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
ADMIN_LOGIN: ${ADMIN_USER}
|
ADMIN_LOGIN: ${ADMIN_USER}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASS}
|
ADMIN_PASSWORD: ${ADMIN_PASS}
|
||||||
|
|||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
echo "[PATCH] Applying 3dp-manager rotation fixes..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^3dp-backend$" && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
SD="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
docker cp "$SD/rotation.service.js" 3dp-backend:/app/dist/src/rotation/rotation.service.js
|
||||||
|
echo "[PATCH] Fixes applied: create-first + orphaned cleanup via API"
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
"use strict";
|
||||||
|
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||||
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||||
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||||
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||||
|
};
|
||||||
|
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||||
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||||
|
};
|
||||||
|
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||||
|
return function (target, key) { decorator(target, key, paramIndex); }
|
||||||
|
};
|
||||||
|
var RotationService_1;
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.RotationService = void 0;
|
||||||
|
const common_1 = require("@nestjs/common");
|
||||||
|
const schedule_1 = require("@nestjs/schedule");
|
||||||
|
const typeorm_1 = require("@nestjs/typeorm");
|
||||||
|
const typeorm_2 = require("typeorm");
|
||||||
|
const subscription_entity_1 = require("../subscriptions/entities/subscription.entity");
|
||||||
|
const inbound_entity_1 = require("../inbounds/entities/inbound.entity");
|
||||||
|
const domain_entity_1 = require("../domains/entities/domain.entity");
|
||||||
|
const setting_entity_1 = require("../settings/entities/setting.entity");
|
||||||
|
const node_entity_1 = require("../nodes/entities/node.entity");
|
||||||
|
const tunnel_entity_1 = require("../tunnels/entities/tunnel.entity");
|
||||||
|
const xui_service_1 = require("../xui/xui.service");
|
||||||
|
const inbound_builder_service_1 = require("../inbounds/inbound-builder.service");
|
||||||
|
const uuid_1 = require("uuid");
|
||||||
|
let RotationService = RotationService_1 = class RotationService {
|
||||||
|
subRepo;
|
||||||
|
inboundRepo;
|
||||||
|
domainRepo;
|
||||||
|
settingRepo;
|
||||||
|
nodeRepo;
|
||||||
|
tunnelRepo;
|
||||||
|
xuiService;
|
||||||
|
inboundBuilder;
|
||||||
|
logger = new common_1.Logger(RotationService_1.name);
|
||||||
|
constructor(subRepo, inboundRepo, domainRepo, settingRepo, nodeRepo, tunnelRepo, xuiService, inboundBuilder) {
|
||||||
|
this.subRepo = subRepo;
|
||||||
|
this.inboundRepo = inboundRepo;
|
||||||
|
this.domainRepo = domainRepo;
|
||||||
|
this.settingRepo = settingRepo;
|
||||||
|
this.nodeRepo = nodeRepo;
|
||||||
|
this.tunnelRepo = tunnelRepo;
|
||||||
|
this.xuiService = xuiService;
|
||||||
|
this.inboundBuilder = inboundBuilder;
|
||||||
|
}
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.initDefaultSettings();
|
||||||
|
}
|
||||||
|
async initDefaultSettings() {
|
||||||
|
const statusKey = 'rotation_status';
|
||||||
|
const intervalKey = 'rotation_interval';
|
||||||
|
const lastRunKey = 'last_rotation_timestamp';
|
||||||
|
const existingStatus = await this.settingRepo.findOne({
|
||||||
|
where: { key: statusKey },
|
||||||
|
});
|
||||||
|
if (!existingStatus) {
|
||||||
|
this.logger.debug(`Инициализация настройки: ${statusKey} = active`);
|
||||||
|
const newSetting = this.settingRepo.create({
|
||||||
|
key: statusKey,
|
||||||
|
value: 'active',
|
||||||
|
});
|
||||||
|
await this.settingRepo.save(newSetting);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.logger.debug(`Текущий статус ротации: ${existingStatus.value}`);
|
||||||
|
}
|
||||||
|
const existingInterval = await this.settingRepo.findOne({
|
||||||
|
where: { key: intervalKey },
|
||||||
|
});
|
||||||
|
if (!existingInterval) {
|
||||||
|
this.logger.debug(`Инициализация настройки: ${intervalKey} = 30`);
|
||||||
|
const newSetting = this.settingRepo.create({
|
||||||
|
key: intervalKey,
|
||||||
|
value: '30',
|
||||||
|
});
|
||||||
|
await this.settingRepo.save(newSetting);
|
||||||
|
}
|
||||||
|
const existingLastRun = await this.settingRepo.findOne({
|
||||||
|
where: { key: lastRunKey },
|
||||||
|
});
|
||||||
|
if (!existingLastRun) {
|
||||||
|
const now = Date.now();
|
||||||
|
this.logger.debug(`Инициализация настройки: ${lastRunKey} = ${now}`);
|
||||||
|
const newSetting = this.settingRepo.create({
|
||||||
|
key: lastRunKey,
|
||||||
|
value: now.toString(),
|
||||||
|
});
|
||||||
|
await this.settingRepo.save(newSetting);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.logger.debug(`Последняя ротация: ${existingLastRun.value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
const statusSetting = await this.settingRepo.findOne({
|
||||||
|
where: { key: 'rotation_status' },
|
||||||
|
});
|
||||||
|
const isStopped = statusSetting?.value === 'stopped';
|
||||||
|
this.logger.debug(`Планировщик: интервал=${intervalMinutes}мин, прошло=${diffMinutes.toFixed(1)}мин, статус=${isStopped ? 'stopped' : 'active'}`);
|
||||||
|
if (diffMinutes < intervalMinutes || isStopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.debug(`Запуск ротации (прошло ${diffMinutes.toFixed(1)}мин при интервале ${intervalMinutes}мин)`);
|
||||||
|
await this.performRotation();
|
||||||
|
await this.saveSetting('last_rotation_timestamp', now.toString());
|
||||||
|
}
|
||||||
|
async saveSetting(key, value) {
|
||||||
|
let s = await this.settingRepo.findOne({ where: { key } });
|
||||||
|
if (!s)
|
||||||
|
s = this.settingRepo.create({ key });
|
||||||
|
s.value = value;
|
||||||
|
await this.settingRepo.save(s);
|
||||||
|
}
|
||||||
|
async performRotation() {
|
||||||
|
this.logger.debug('Запуск плановой ротации...');
|
||||||
|
const defaultNode = await this.getDefaultNode();
|
||||||
|
const isLoginSuccess = defaultNode ? true : await this.xuiService.login();
|
||||||
|
if (!isLoginSuccess) {
|
||||||
|
this.logger.error('Отмена ротации: Не удалось войти в панель 3x-ui');
|
||||||
|
return { success: false, message: 'Не удалось войти в панель 3x-ui' };
|
||||||
|
}
|
||||||
|
const subscriptions = await this.subRepo.find({
|
||||||
|
where: {
|
||||||
|
isEnabled: true,
|
||||||
|
isAutoRotationEnabled: true,
|
||||||
|
},
|
||||||
|
relations: ['inbounds', 'inbounds.node', 'node', 'relayServer'],
|
||||||
|
});
|
||||||
|
if (subscriptions.length === 0) {
|
||||||
|
return { success: false, message: 'Нет активных подписок для ротации' };
|
||||||
|
}
|
||||||
|
const domains = await this.domainRepo.find({ where: { isEnabled: true } });
|
||||||
|
if (domains.length === 0) {
|
||||||
|
this.logger.warn('Список доменов пуст! Ротация невозможна.');
|
||||||
|
return { success: false, message: 'Список доменов пуст!' };
|
||||||
|
}
|
||||||
|
for (const sub of subscriptions) {
|
||||||
|
const rotated = await this.rotateSubscription(sub, domains, defaultNode);
|
||||||
|
if (!rotated) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to delete old inbounds',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.debug('Ротация завершена.');
|
||||||
|
return { success: true, message: 'Ротация успешно выполнена' };
|
||||||
|
}
|
||||||
|
async rotateSubscription(sub, domains, defaultNode) {
|
||||||
|
this.logger.debug(`Ротация для подписки: ${sub.name} (${sub.uuid})`);
|
||||||
|
|
||||||
|
const baseNode = sub.node ?? defaultNode ?? undefined;
|
||||||
|
const keys = await this.xuiService.getNewX25519Cert(baseNode);
|
||||||
|
if (!keys) {
|
||||||
|
this.logger.error('Не удалось получить Reality ключи, пропускаем подписку');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const usedPorts = new Set();
|
||||||
|
const host = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
|
||||||
|
const serverAddress = this.getNodeAddress(baseNode) || host?.value || 'localhost';
|
||||||
|
const flag = await this.settingRepo.findOne({
|
||||||
|
where: { key: 'xui_geo_flag' },
|
||||||
|
});
|
||||||
|
const defaultFlagEmoji = flag?.value ?? '%F0%9F%92%AF';
|
||||||
|
const inboundsConfig = sub.inboundsConfig || [];
|
||||||
|
for (const config of inboundsConfig) {
|
||||||
|
const type = config.type;
|
||||||
|
const uuid = (0, uuid_1.v4)();
|
||||||
|
const targetNode = await this.resolveNode(config.nodeId, sub.node, defaultNode);
|
||||||
|
const resolvedRelay = await this.resolveRelay(config.relayServerId, sub.relayServer);
|
||||||
|
const relayServer = resolvedRelay && this.isRelayAvailableForNode(resolvedRelay, targetNode)
|
||||||
|
? resolvedRelay
|
||||||
|
: undefined;
|
||||||
|
const targetAddress = relayServer?.domain ||
|
||||||
|
relayServer?.ip ||
|
||||||
|
this.getNodeAddress(targetNode) ||
|
||||||
|
serverAddress;
|
||||||
|
const flagEmoji = config.flag || targetNode?.flag || defaultFlagEmoji;
|
||||||
|
let sni = '';
|
||||||
|
if (type === 'custom') {
|
||||||
|
const newInbound = this.inboundRepo.create({
|
||||||
|
xuiId: 0,
|
||||||
|
port: 0,
|
||||||
|
protocol: 'custom',
|
||||||
|
remark: 'custom-link',
|
||||||
|
link: config.link || '',
|
||||||
|
subscription: sub,
|
||||||
|
});
|
||||||
|
await this.inboundRepo.save(newInbound);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
sni = config.sni === 'random' ? this.pickDomain(domains) : config.sni;
|
||||||
|
}
|
||||||
|
if (type === 'hysteria2-udp') {
|
||||||
|
let port = 0;
|
||||||
|
if (config.port === 'random' || !config.port) {
|
||||||
|
port = await this.getFreePort(0, usedPorts);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
port =
|
||||||
|
typeof config.port === 'string'
|
||||||
|
? parseInt(config.port, 10)
|
||||||
|
: config.port;
|
||||||
|
}
|
||||||
|
usedPorts.add(port);
|
||||||
|
const hysteriaSni = this.getNodeAddress(targetNode) || serverAddress;
|
||||||
|
const hysteriaConfig = this.inboundBuilder.buildHysteria2Inbound({
|
||||||
|
port,
|
||||||
|
uuid,
|
||||||
|
sni: hysteriaSni,
|
||||||
|
certificateFile: config.certificateFile,
|
||||||
|
keyFile: config.keyFile,
|
||||||
|
});
|
||||||
|
if (config.name?.trim()) {
|
||||||
|
hysteriaConfig.remark = config.name.trim();
|
||||||
|
}
|
||||||
|
const xuiId = await this.xuiService.addInbound(hysteriaConfig, targetNode);
|
||||||
|
if (!xuiId) {
|
||||||
|
this.logger.warn('Hysteria2 inbound was not created by 3x-ui; skipping subscription link for this inbound');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const link = this.inboundBuilder.buildInboundLink(hysteriaConfig, targetAddress, uuid, flagEmoji);
|
||||||
|
const newInbound = this.inboundRepo.create({
|
||||||
|
xuiId,
|
||||||
|
port,
|
||||||
|
protocol: 'hysteria2',
|
||||||
|
remark: hysteriaConfig.remark,
|
||||||
|
link: link,
|
||||||
|
subscription: sub,
|
||||||
|
node: targetNode,
|
||||||
|
relayServer,
|
||||||
|
});
|
||||||
|
await this.inboundRepo.save(newInbound);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let port = 0;
|
||||||
|
if (config.port === 'random' || !config.port) {
|
||||||
|
port = await this.getFreePort(0, usedPorts);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
port =
|
||||||
|
typeof config.port === 'string'
|
||||||
|
? parseInt(config.port, 10)
|
||||||
|
: config.port;
|
||||||
|
}
|
||||||
|
usedPorts.add(port);
|
||||||
|
let xuiConfig = null;
|
||||||
|
switch (type) {
|
||||||
|
case 'vless-tcp-reality':
|
||||||
|
xuiConfig = this.inboundBuilder.buildVlessRealityTcp({
|
||||||
|
port,
|
||||||
|
uuid,
|
||||||
|
sni,
|
||||||
|
...keys,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'vless-xhttp-reality':
|
||||||
|
xuiConfig = this.inboundBuilder.buildVlessRealityXhttp({
|
||||||
|
port,
|
||||||
|
uuid,
|
||||||
|
sni,
|
||||||
|
...keys,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'vless-grpc-reality':
|
||||||
|
xuiConfig = this.inboundBuilder.buildVlessRealityGrpc({
|
||||||
|
port,
|
||||||
|
uuid,
|
||||||
|
sni,
|
||||||
|
...keys,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'vless-ws':
|
||||||
|
xuiConfig = this.inboundBuilder.buildVlessWs({ port, uuid, sni });
|
||||||
|
break;
|
||||||
|
case 'vmess-tcp':
|
||||||
|
xuiConfig = this.inboundBuilder.buildVmessTcp({ port, uuid });
|
||||||
|
break;
|
||||||
|
case 'shadowsocks-tcp':
|
||||||
|
xuiConfig = this.inboundBuilder.buildShadowsocksTcp({ port, uuid });
|
||||||
|
break;
|
||||||
|
case 'trojan-tcp-reality':
|
||||||
|
xuiConfig = this.inboundBuilder.buildTrojanRealityTcp({
|
||||||
|
port,
|
||||||
|
uuid,
|
||||||
|
sni,
|
||||||
|
...keys,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(`Неизвестный тип инбаунда: ${type}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (config.name?.trim()) {
|
||||||
|
xuiConfig.remark = config.name.trim();
|
||||||
|
}
|
||||||
|
const xuiId = await this.xuiService.addInbound(xuiConfig, targetNode);
|
||||||
|
if (xuiId && xuiConfig) {
|
||||||
|
const settings = JSON.parse(xuiConfig.settings);
|
||||||
|
const idOrPass = settings.clients?.[0]?.id || settings.clients?.[0]?.password || '';
|
||||||
|
const fullLink = this.inboundBuilder.buildInboundLink(xuiConfig, targetAddress, idOrPass, flagEmoji);
|
||||||
|
const newInbound = this.inboundRepo.create({
|
||||||
|
xuiId: xuiId,
|
||||||
|
port: port,
|
||||||
|
protocol: xuiConfig.protocol,
|
||||||
|
remark: xuiConfig.remark,
|
||||||
|
link: fullLink,
|
||||||
|
subscription: sub,
|
||||||
|
node: targetNode,
|
||||||
|
relayServer,
|
||||||
|
});
|
||||||
|
await this.inboundRepo.save(newInbound);
|
||||||
|
}
|
||||||
|
} // ============ УДАЛЕНИЕ СТАРЫХ ИНБАУНДОВ ПОСЛЕ СОЗДАНИЯ НОВЫХ ============
|
||||||
|
if (sub.inbounds && sub.inbounds.length > 0) {
|
||||||
|
for (const inbound of sub.inbounds) {
|
||||||
|
try {
|
||||||
|
if (inbound.xuiId && inbound.xuiId > 0) {
|
||||||
|
const nodeToDelete = await this.resolveInboundNode(inbound);
|
||||||
|
const isDeleted = await this.xuiService.deleteInbound(inbound.xuiId, nodeToDelete);
|
||||||
|
if (!isDeleted) {
|
||||||
|
this.logger.warn("Не удалось удалить старый инбаунд " + inbound.xuiId + " с панели (продолжаем)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.inboundRepo.delete(inbound.id);
|
||||||
|
this.logger.debug("Старый инбаунд " + (inbound.xuiId || inbound.id) + " удалён из БД");
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn("Ошибка при удалении старого инбаунда " + (inbound.xuiId || inbound.id) + ": " + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.debug("Нет старых инбаундов в БД для подписки " + sub.name + " (пропускаем удаление)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CLEANUP ORPHANED INBOUNDS & CLIENTS VIA API =====
|
||||||
|
try {
|
||||||
|
const nodeForCleanup = sub.node ?? defaultNode ?? undefined;
|
||||||
|
const api = await this.xuiService.createAuthenticatedApi(nodeForCleanup);
|
||||||
|
if (api) {
|
||||||
|
const listResp = await api.get("/panel/api/inbounds/list");
|
||||||
|
const allInbounds = listResp.data?.obj || [];
|
||||||
|
this.logger.debug("API cleanup: " + allInbounds.length + " inbounds on 3x-ui");
|
||||||
|
const allKnownIds = await this.inboundRepo
|
||||||
|
.createQueryBuilder("inbound")
|
||||||
|
.select("inbound.xuiId")
|
||||||
|
.where("inbound.xuiId > 0")
|
||||||
|
.getRawMany();
|
||||||
|
const knownSet = new Set(allKnownIds.map(r => Number(r.inbound_xuiId)));
|
||||||
|
let deletedCount = 0;
|
||||||
|
for (const remote of allInbounds) {
|
||||||
|
if (!knownSet.has(remote.id)) {
|
||||||
|
const wasDeleted = await this.xuiService.deleteInbound(remote.id, nodeForCleanup);
|
||||||
|
if (wasDeleted) deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
this.logger.log("Cleaned up " + deletedCount + " orphaned inbound(s) via API (clients auto-removed)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn("API cleanup failed: " + e.message);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pickDomain(list) {
|
||||||
|
return list[Math.floor(Math.random() * list.length)].name;
|
||||||
|
}
|
||||||
|
async getFreePort(preferred, currentBatch) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async rotateSingleSubscription(subscriptionId) {
|
||||||
|
this.logger.debug(`Запуск ручной ротации подписки: ${subscriptionId}`);
|
||||||
|
const sub = await this.subRepo.findOne({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
relations: ['inbounds', 'inbounds.node', 'node', 'relayServer'],
|
||||||
|
});
|
||||||
|
if (!sub) {
|
||||||
|
this.logger.warn(`Подписка не найдена: ${subscriptionId}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Подписка не найдена',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const defaultNode = await this.getDefaultNode();
|
||||||
|
const isLoginSuccess = defaultNode ? true : await this.xuiService.login();
|
||||||
|
if (!isLoginSuccess) {
|
||||||
|
this.logger.error('Отмена ротации: Не удалось войти в панель 3x-ui');
|
||||||
|
return { success: false, message: 'Не удалось войти в панель 3x-ui' };
|
||||||
|
}
|
||||||
|
const domains = await this.domainRepo.find({ where: { isEnabled: true } });
|
||||||
|
if (domains.length === 0) {
|
||||||
|
this.logger.warn('Список доменов пуст! Ротация невозможна.');
|
||||||
|
return { success: false, message: 'Список доменов пуст!' };
|
||||||
|
}
|
||||||
|
const rotated = await this.rotateSubscription(sub, domains, defaultNode);
|
||||||
|
if (!rotated) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Failed to delete old inbounds',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.logger.debug(`Ручная ротация подписки ${subscriptionId} завершена.`);
|
||||||
|
return { success: true, message: 'Ротация успешно выполнена' };
|
||||||
|
}
|
||||||
|
async getDefaultNode() {
|
||||||
|
return this.nodeRepo
|
||||||
|
.createQueryBuilder('node')
|
||||||
|
.addSelect('node.password')
|
||||||
|
.addSelect('node.token')
|
||||||
|
.where('node.isMain = :isMain', { isMain: true })
|
||||||
|
.getOne();
|
||||||
|
}
|
||||||
|
async resolveNode(nodeId, subscriptionNode, defaultNode) {
|
||||||
|
if (!nodeId)
|
||||||
|
return subscriptionNode ?? defaultNode ?? undefined;
|
||||||
|
return ((await this.nodeRepo
|
||||||
|
.createQueryBuilder('node')
|
||||||
|
.addSelect('node.password')
|
||||||
|
.addSelect('node.token')
|
||||||
|
.where('node.id = :nodeId', { nodeId })
|
||||||
|
.getOne()) ??
|
||||||
|
subscriptionNode ??
|
||||||
|
defaultNode ??
|
||||||
|
undefined);
|
||||||
|
}
|
||||||
|
async resolveInboundNode(inbound) {
|
||||||
|
if (!inbound.nodeId)
|
||||||
|
return inbound.node;
|
||||||
|
return ((await this.nodeRepo
|
||||||
|
.createQueryBuilder('node')
|
||||||
|
.addSelect('node.password')
|
||||||
|
.addSelect('node.token')
|
||||||
|
.where('node.id = :nodeId', { nodeId: inbound.nodeId })
|
||||||
|
.getOne()) ?? inbound.node);
|
||||||
|
}
|
||||||
|
async resolveRelay(relayServerId, subscriptionRelay) {
|
||||||
|
if (!relayServerId)
|
||||||
|
return subscriptionRelay ?? undefined;
|
||||||
|
return (await this.tunnelRepo.findOne({ where: { id: relayServerId } })) ?? undefined;
|
||||||
|
}
|
||||||
|
isRelayAvailableForNode(relay, node) {
|
||||||
|
if (!relay.nodeId)
|
||||||
|
return true;
|
||||||
|
return Boolean(node?.id && relay.nodeId === node.id);
|
||||||
|
}
|
||||||
|
getNodeAddress(node) {
|
||||||
|
if (!node)
|
||||||
|
return undefined;
|
||||||
|
if (node.domain)
|
||||||
|
return node.domain;
|
||||||
|
if (node.ip)
|
||||||
|
return node.ip;
|
||||||
|
if (node.host)
|
||||||
|
return node.host;
|
||||||
|
try {
|
||||||
|
return node.url ? new URL(node.url).hostname : undefined;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return node.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.RotationService = RotationService;
|
||||||
|
__decorate([
|
||||||
|
(0, schedule_1.Cron)(schedule_1.CronExpression.EVERY_MINUTE),
|
||||||
|
__metadata("design:type", Function),
|
||||||
|
__metadata("design:paramtypes", []),
|
||||||
|
__metadata("design:returntype", Promise)
|
||||||
|
], RotationService.prototype, "handleTicker", null);
|
||||||
|
exports.RotationService = RotationService = RotationService_1 = __decorate([
|
||||||
|
(0, common_1.Injectable)(),
|
||||||
|
__param(0, (0, typeorm_1.InjectRepository)(subscription_entity_1.Subscription)),
|
||||||
|
__param(1, (0, typeorm_1.InjectRepository)(inbound_entity_1.Inbound)),
|
||||||
|
__param(2, (0, typeorm_1.InjectRepository)(domain_entity_1.Domain)),
|
||||||
|
__param(3, (0, typeorm_1.InjectRepository)(setting_entity_1.Setting)),
|
||||||
|
__param(4, (0, typeorm_1.InjectRepository)(node_entity_1.Node)),
|
||||||
|
__param(5, (0, typeorm_1.InjectRepository)(tunnel_entity_1.Tunnel)),
|
||||||
|
__metadata("design:paramtypes", [typeorm_2.Repository,
|
||||||
|
typeorm_2.Repository,
|
||||||
|
typeorm_2.Repository,
|
||||||
|
typeorm_2.Repository,
|
||||||
|
typeorm_2.Repository,
|
||||||
|
typeorm_2.Repository,
|
||||||
|
xui_service_1.XuiService,
|
||||||
|
inbound_builder_service_1.InboundBuilderService])
|
||||||
|
], RotationService);
|
||||||
|
//# sourceMappingURL=rotation.service.js.map
|
||||||
@@ -24,6 +24,9 @@ import { Tunnel } from './tunnels/entities/tunnel.entity';
|
|||||||
import { SessionModule } from './session/session.module';
|
import { SessionModule } from './session/session.module';
|
||||||
import { Node } from './nodes/entities/node.entity';
|
import { Node } from './nodes/entities/node.entity';
|
||||||
import { NodesModule } from './nodes/nodes.module';
|
import { NodesModule } from './nodes/nodes.module';
|
||||||
|
import { AddNodesAndNodeRelations1765960000000 } from './migrations/1765960000000-add-nodes-and-node-relations';
|
||||||
|
import { AddNodeIpFlagAndInboundLabels1770000000000 } from './migrations/1770000000000-add-node-ip-flag-and-inbound-labels';
|
||||||
|
import { AddNodeDomain1770000000001 } from './migrations/1770000000001-add-node-domain';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -44,7 +47,13 @@ import { NodesModule } from './nodes/nodes.module';
|
|||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
entities: [Setting, Domain, Subscription, Inbound, Tunnel, Node],
|
entities: [Setting, Domain, Subscription, Inbound, Tunnel, Node],
|
||||||
synchronize: true,
|
migrations: [
|
||||||
|
AddNodesAndNodeRelations1765960000000,
|
||||||
|
AddNodeIpFlagAndInboundLabels1770000000000,
|
||||||
|
AddNodeDomain1770000000001,
|
||||||
|
],
|
||||||
|
synchronize: process.env.DB_SYNCHRONIZE !== 'false',
|
||||||
|
migrationsRun: process.env.DB_MIGRATIONS_RUN === 'true',
|
||||||
}),
|
}),
|
||||||
SessionModule,
|
SessionModule,
|
||||||
XuiModule,
|
XuiModule,
|
||||||
|
|||||||
@@ -469,7 +469,6 @@ export class InboundBuilderService {
|
|||||||
content: '',
|
content: '',
|
||||||
dir: '',
|
dir: '',
|
||||||
headers: {},
|
headers: {},
|
||||||
insecure: true,
|
|
||||||
rewriteHost: false,
|
rewriteHost: false,
|
||||||
statusCode: 0,
|
statusCode: 0,
|
||||||
type: 'proxy',
|
type: 'proxy',
|
||||||
@@ -703,7 +702,6 @@ export class InboundBuilderService {
|
|||||||
const auth = settings.clients?.[0]?.auth || settings.clients?.[0]?.password || password;
|
const auth = settings.clients?.[0]?.auth || settings.clients?.[0]?.password || password;
|
||||||
const finalmask = stream.finalmask?.udp?.[0];
|
const finalmask = stream.finalmask?.udp?.[0];
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('insecure', '1');
|
|
||||||
params.set('security', 'tls');
|
params.set('security', 'tls');
|
||||||
params.set('fp', 'chrome');
|
params.set('fp', 'chrome');
|
||||||
params.set('alpn', 'h3');
|
params.set('alpn', 'h3');
|
||||||
@@ -769,7 +767,6 @@ export class InboundBuilderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('insecure', '1');
|
|
||||||
params.set('security', 'tls');
|
params.set('security', 'tls');
|
||||||
params.set('fp', 'chrome');
|
params.set('fp', 'chrome');
|
||||||
params.set('alpn', 'h3');
|
params.set('alpn', 'h3');
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddNodeDomain1770000000001 implements MigrationInterface {
|
||||||
|
name = 'AddNodeDomain1770000000001';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "node"
|
||||||
|
ADD COLUMN IF NOT EXISTS "domain" character varying
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "node" DROP COLUMN IF EXISTS "domain"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,9 +38,8 @@ check_containers_running() {
|
|||||||
# Формат: NAME\tSTATUS (например: "3dp-postgres\tUp 2 days" или "3dp-postgres\tError")
|
# Формат: NAME\tSTATUS (например: "3dp-postgres\tUp 2 days" или "3dp-postgres\tError")
|
||||||
while IFS=$'\t' read -r container_name status; do
|
while IFS=$'\t' read -r container_name status; do
|
||||||
if [ -n "$container_name" ] && [ -n "$status" ]; then
|
if [ -n "$container_name" ] && [ -n "$status" ]; then
|
||||||
# Проверяем, что статус содержит Up/running/healthy/restarting
|
# Restarting означает, что контейнер не смог стабильно запуститься.
|
||||||
# Up, Up 2 days, Up Less than a second, (healthy), running, restarting
|
if ! echo "$status" | grep -qiE "^up|running|healthy"; then
|
||||||
if ! echo "$status" | grep -qiE "^up|running|healthy|restarting"; then
|
|
||||||
failed=1
|
failed=1
|
||||||
warn "Контейнер $container_name в статусе: $status"
|
warn "Контейнер $container_name в статусе: $status"
|
||||||
fi
|
fi
|
||||||
@@ -284,6 +283,52 @@ remove_hysteria_mount() {
|
|||||||
mv "$tmp_file" "$compose_file"
|
mv "$tmp_file" "$compose_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_safe_database_mode() {
|
||||||
|
local compose_file="$1"
|
||||||
|
[[ -f "$compose_file" ]] || return 0
|
||||||
|
|
||||||
|
local tmp_file
|
||||||
|
tmp_file="$(mktemp)"
|
||||||
|
|
||||||
|
awk '
|
||||||
|
/^[[:space:]]+DB_SYNCHRONIZE:/ { next }
|
||||||
|
/^[[:space:]]+DB_MIGRATIONS_RUN:/ { next }
|
||||||
|
{
|
||||||
|
print $0
|
||||||
|
if ($0 ~ /^[[:space:]]+DB_NAME:/) {
|
||||||
|
match($0, /^[[:space:]]+/)
|
||||||
|
indent = substr($0, RSTART, RLENGTH)
|
||||||
|
print indent "DB_SYNCHRONIZE: \"false\""
|
||||||
|
print indent "DB_MIGRATIONS_RUN: \"true\""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$compose_file" > "$tmp_file"
|
||||||
|
|
||||||
|
mv "$tmp_file" "$compose_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_node_count() {
|
||||||
|
docker exec 3dp-postgres sh -c '
|
||||||
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atqc "
|
||||||
|
SELECT CASE
|
||||||
|
WHEN to_regclass('\''public.node'\'') IS NULL THEN 0
|
||||||
|
ELSE (SELECT count(*) FROM node)
|
||||||
|
END
|
||||||
|
"
|
||||||
|
' 2>/dev/null | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_database() {
|
||||||
|
local backup_dir="$PROJECT_DIR/backups"
|
||||||
|
BACKUP_FILE="$backup_dir/pre-update-$(date +%Y%m%d-%H%M%S).sql.gz"
|
||||||
|
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
log "Создание резервной копии базы данных: $BACKUP_FILE"
|
||||||
|
docker exec 3dp-postgres sh -c \
|
||||||
|
'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' | gzip > "$BACKUP_FILE"
|
||||||
|
chmod 600 "$BACKUP_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
need_root() {
|
need_root() {
|
||||||
[[ $EUID -eq 0 ]] || die "Запускать только от root"
|
[[ $EUID -eq 0 ]] || die "Запускать только от root"
|
||||||
}
|
}
|
||||||
@@ -314,7 +359,11 @@ log "Compose команда: ${COMPOSE_CMD[*]}"
|
|||||||
#################################
|
#################################
|
||||||
# CHECK AND FIX CREDENTIALS
|
# CHECK AND FIX CREDENTIALS
|
||||||
#################################
|
#################################
|
||||||
check_and_fix_credentials || true
|
# Обновление не должно менять пароль уже инициализированной PostgreSQL:
|
||||||
|
# изменение только .env делает существующую базу недоступной.
|
||||||
|
if [[ ! -f ".env" ]]; then
|
||||||
|
die "Файл .env не найден. Обновление остановлено, чтобы не потерять доступ к существующей базе данных"
|
||||||
|
fi
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# FIX NGINX CONFIG
|
# FIX NGINX CONFIG
|
||||||
@@ -322,6 +371,16 @@ check_and_fix_credentials || true
|
|||||||
ensure_nginx_api_timeouts "$PROJECT_DIR/client/nginx-client.conf"
|
ensure_nginx_api_timeouts "$PROJECT_DIR/client/nginx-client.conf"
|
||||||
ensure_bus_location "$PROJECT_DIR/client/nginx-client.conf"
|
ensure_bus_location "$PROJECT_DIR/client/nginx-client.conf"
|
||||||
remove_hysteria_mount "$PROJECT_DIR/docker-compose.yml"
|
remove_hysteria_mount "$PROJECT_DIR/docker-compose.yml"
|
||||||
|
ensure_safe_database_mode "$PROJECT_DIR/docker-compose.yml"
|
||||||
|
|
||||||
|
#################################
|
||||||
|
# BACKUP DATABASE
|
||||||
|
#################################
|
||||||
|
node_count_before="$(get_node_count)"
|
||||||
|
[[ "$node_count_before" =~ ^[0-9]+$ ]] || die "Не удалось проверить количество нод перед обновлением"
|
||||||
|
backup_database
|
||||||
|
backup_file="$BACKUP_FILE"
|
||||||
|
[[ -s "$backup_file" ]] || die "Не удалось создать резервную копию базы данных"
|
||||||
|
|
||||||
#################################
|
#################################
|
||||||
# REBUILD BACKEND
|
# REBUILD BACKEND
|
||||||
@@ -341,11 +400,18 @@ log "Пересоздание контейнеров..."
|
|||||||
|
|
||||||
# Проверка: все ли контейнеры запустились
|
# Проверка: все ли контейнеры запустились
|
||||||
if ! check_containers_running 60; then
|
if ! check_containers_running 60; then
|
||||||
error "Не удалось запустить контейнеры. Логи:"
|
warn "Не удалось запустить контейнеры. Логи:"
|
||||||
"${COMPOSE_CMD[@]}" logs --tail=50
|
"${COMPOSE_CMD[@]}" logs --tail=50
|
||||||
die "Обновление прервано из-за ошибки запуска контейнеров"
|
die "Обновление прервано из-за ошибки запуска контейнеров"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
node_count_after="$(get_node_count)"
|
||||||
|
[[ "$node_count_after" =~ ^[0-9]+$ ]] || die "Не удалось проверить количество нод после обновления. Резервная копия: $backup_file"
|
||||||
|
if (( node_count_after < node_count_before )); then
|
||||||
|
"${COMPOSE_CMD[@]}" stop backend || true
|
||||||
|
die "Количество нод уменьшилось с $node_count_before до $node_count_after. Обновление остановлено, резервная копия: $backup_file"
|
||||||
|
fi
|
||||||
|
|
||||||
log "Очистка старых Docker-образов (освобождение места)..."
|
log "Очистка старых Docker-образов (освобождение места)..."
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user