Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f210d3ef15 | |||
| 842e4283c1 | |||
| a28d7a6954 | |||
| 009506f915 | |||
| e84d77f248 | |||
| f52e035d8a |
@@ -1,3 +1,4 @@
|
||||
checker/
|
||||
client/.env
|
||||
client/coverage/
|
||||
backups/
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
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 { 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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user