8 Commits

Author SHA1 Message Date
DenPiligrim f210d3ef15 Merge pull request #59 from xFalconMH/fix-rotation-order
fix: rotation order and orphaned cleanup via API
2026-06-11 11:21:03 +03:00
FalconMHDota 842e4283c1 fix: rotation order + orphaned cleanup via API 2026-06-09 21:37:39 +00:00
DenPiligrim a28d7a6954 Merge pull request #58 from denpiligrim/dp-fix
fix: bd sync
2026-06-08 23:35:25 +03:00
Den Piligrim 009506f915 fix: bd sync 2026-06-08 23:31:17 +03:00
DenPiligrim e84d77f248 Merge pull request #57 from denpiligrim/dp-fix
fix: insecure hy2
2026-06-08 22:48:26 +03:00
Den Piligrim f52e035d8a fix: insecure hy2 2026-06-08 22:47:25 +03:00
DenPiligrim 927a04f24a Merge pull request #55 from denpiligrim/dp-fix
...
2026-06-03 00:08:55 +03:00
Den Piligrim 600b398a5a ...
Co-Authored-By: Codex <267193182+codex@users.noreply.github.com>
2026-06-03 00:08:08 +03:00
10 changed files with 636 additions and 11 deletions
+1
View File
@@ -1,3 +1,4 @@
checker/
client/.env
client/coverage/
backups/
+2 -2
View File
@@ -2,7 +2,7 @@
<p><img src="https://denpiligrim.ru/storage/images/3dp-manager.png" alt="3dp-manager preview"></p>
![Version](https://img.shields.io/badge/version-2.2.0-blue.svg) ![Downloads](https://img.shields.io/badge/downloads-5.1k-blue) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
![Version](https://img.shields.io/badge/version-2.2.0-blue.svg) ![Downloads](https://img.shields.io/badge/downloads-5.6k-blue) [![License](https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true)](https://www.gnu.org/licenses/gpl-3.0) [![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=flat&logo=telegram&logoColor=white)](https://t.me/denpiligrim_web) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCOv2tFFYDY4mXOM60PVz8zw)](https://www.youtube.com/@denpiligrim)
# 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`
- На электронный кошелек:
- :moneybag: ЮМоney: `4100116897060652`
+2
View File
@@ -26,6 +26,8 @@ services:
DB_USERNAME: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB}
DB_SYNCHRONIZE: "true"
DB_MIGRATIONS_RUN: "false"
JWT_SECRET: ${JWT_SECRET}
ADMIN_LOGIN: ${ADMIN_LOGIN}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
+4
View File
@@ -469,6 +469,8 @@ services:
DB_USERNAME: admin
DB_PASSWORD: ${DB_PASS}
DB_NAME: 3dp_manager
DB_SYNCHRONIZE: "true"
DB_MIGRATIONS_RUN: "false"
JWT_SECRET: ${JWT_SECRET}
ADMIN_LOGIN: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASS}
@@ -580,6 +582,8 @@ services:
DB_USERNAME: admin
DB_PASSWORD: ${DB_PASS}
DB_NAME: 3dp_manager
DB_SYNCHRONIZE: "true"
DB_MIGRATIONS_RUN: "false"
JWT_SECRET: ${JWT_SECRET}
ADMIN_LOGIN: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASS}
+10
View File
@@ -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"
+518
View File
@@ -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
+10 -1
View File
@@ -24,6 +24,9 @@ import { Tunnel } from './tunnels/entities/tunnel.entity';
import { SessionModule } from './session/session.module';
import { Node } from './nodes/entities/node.entity';
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({
imports: [
@@ -44,7 +47,13 @@ import { NodesModule } from './nodes/nodes.module';
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
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,
XuiModule,
@@ -469,7 +469,6 @@ export class InboundBuilderService {
content: '',
dir: '',
headers: {},
insecure: true,
rewriteHost: false,
statusCode: 0,
type: 'proxy',
@@ -703,7 +702,6 @@ export class InboundBuilderService {
const auth = settings.clients?.[0]?.auth || settings.clients?.[0]?.password || password;
const finalmask = stream.finalmask?.udp?.[0];
const params = new URLSearchParams();
params.set('insecure', '1');
params.set('security', 'tls');
params.set('fp', 'chrome');
params.set('alpn', 'h3');
@@ -769,7 +767,6 @@ export class InboundBuilderService {
}
const params = new URLSearchParams();
params.set('insecure', '1');
params.set('security', 'tls');
params.set('fp', 'chrome');
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"`,
);
}
}
+71 -5
View File
@@ -38,9 +38,8 @@ check_containers_running() {
# Формат: NAME\tSTATUS (например: "3dp-postgres\tUp 2 days" или "3dp-postgres\tError")
while IFS=$'\t' read -r container_name status; do
if [ -n "$container_name" ] && [ -n "$status" ]; then
# Проверяем, что статус содержит Up/running/healthy/restarting
# Up, Up 2 days, Up Less than a second, (healthy), running, restarting
if ! echo "$status" | grep -qiE "^up|running|healthy|restarting"; then
# Restarting означает, что контейнер не смог стабильно запуститься.
if ! echo "$status" | grep -qiE "^up|running|healthy"; then
failed=1
warn "Контейнер $container_name в статусе: $status"
fi
@@ -284,6 +283,52 @@ remove_hysteria_mount() {
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() {
[[ $EUID -eq 0 ]] || die "Запускать только от root"
}
@@ -314,7 +359,11 @@ log "Compose команда: ${COMPOSE_CMD[*]}"
#################################
# CHECK AND FIX CREDENTIALS
#################################
check_and_fix_credentials || true
# Обновление не должно менять пароль уже инициализированной PostgreSQL:
# изменение только .env делает существующую базу недоступной.
if [[ ! -f ".env" ]]; then
die "Файл .env не найден. Обновление остановлено, чтобы не потерять доступ к существующей базе данных"
fi
#################################
# FIX NGINX CONFIG
@@ -322,6 +371,16 @@ check_and_fix_credentials || true
ensure_nginx_api_timeouts "$PROJECT_DIR/client/nginx-client.conf"
ensure_bus_location "$PROJECT_DIR/client/nginx-client.conf"
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
@@ -341,11 +400,18 @@ log "Пересоздание контейнеров..."
# Проверка: все ли контейнеры запустились
if ! check_containers_running 60; then
error "Не удалось запустить контейнеры. Логи:"
warn "Не удалось запустить контейнеры. Логи:"
"${COMPOSE_CMD[@]}" logs --tail=50
die "Обновление прервано из-за ошибки запуска контейнеров"
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-образов (освобождение места)..."
docker image prune -f