Files
3dp-manager/server/src/rotation/rotation.service.ts
T
2026-03-28 23:46:42 +03:00

388 lines
13 KiB
TypeScript

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } 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 { XuiInboundRaw } from '../inbounds/xui-inbound.types';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class RotationService implements OnModuleInit {
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,
) {}
async onModuleInit() {
await this.initDefaultSettings();
}
private 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}`);
}
// Инициализация интервала ротации (по умолчанию 30 минут)
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);
}
// Инициализация last_rotation_timestamp (текущее время, чтобы не было ложной ротации при старте)
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}`);
}
}
@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;
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());
}
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);
}
async performRotation() {
this.logger.debug('Запуск плановой ротации...');
const isLoginSuccess = 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'],
});
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) {
await this.rotateSubscription(sub, domains);
}
this.logger.debug('Ротация завершена.');
return { success: true, message: 'Ротация успешно выполнена' };
}
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
this.logger.debug(`Ротация для подписки: ${sub.name} (${sub.uuid})`);
// Удаляем старые инбаунды
if (sub.inbounds && sub.inbounds.length > 0) {
for (const inbound of sub.inbounds) {
if (inbound.xuiId && inbound.xuiId > 0) {
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 host = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
const serverAddress = host?.value || 'localhost';
const flag = await this.settingRepo.findOne({
where: { key: 'xui_geo_flag' },
});
const flagEmoji = flag?.value ?? '%F0%9F%92%AF';
// Получаем конфиг или пустой массив
const inboundsConfig = sub.inboundsConfig || [];
for (const config of inboundsConfig) {
const type = config.type;
const uuid = uuidv4();
let sni = '';
// === 1. Обработка Custom ===
if (type === 'custom') {
const newInbound = this.inboundRepo.create({
xuiId: 0, // Не привязано к 3x-ui
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;
}
// === 2. Обработка Hysteria2 ===
if (type === 'hysteria2-udp') {
const link = this.inboundBuilder.buildHysteria2Link(
serverAddress,
sni,
flagEmoji + '%20hysteria2-udp',
);
const newInbound = this.inboundRepo.create({
xuiId: 0,
port: 0, // Обычно Hysteria висит на 443, фактический порт вытаскивается в билдере
protocol: 'hysteria2',
remark: 'hysteria2-udp',
link: link,
subscription: sub,
});
await this.inboundRepo.save(newInbound);
continue;
}
// === 3. Обработка стандартных инбаундов Xray (3x-ui) ===
// Определяем порт
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: XuiInboundRaw | null = 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;
}
const xuiId = await this.xuiService.addInbound(xuiConfig);
if (xuiId && xuiConfig) {
const settings = JSON.parse(xuiConfig.settings) as {
clients?: Array<{ id?: string; password?: string }>;
};
const idOrPass =
settings.clients?.[0]?.id || settings.clients?.[0]?.password || '';
const fullLink = this.inboundBuilder.buildInboundLink(
xuiConfig,
serverAddress,
idOrPass,
flagEmoji,
);
const newInbound = this.inboundRepo.create({
xuiId: xuiId,
port: port,
protocol: xuiConfig.protocol,
remark: xuiConfig.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;
}
}
/**
* Ручная ротация одной подписки (независимо от флага isAutoRotationEnabled)
*/
async rotateSingleSubscription(subscriptionId: string) {
this.logger.debug(`Запуск ручной ротации подписки: ${subscriptionId}`);
const sub = await this.subRepo.findOne({
where: { id: subscriptionId },
relations: ['inbounds'],
});
if (!sub) {
this.logger.warn(`Подписка не найдена: ${subscriptionId}`);
return {
success: false,
message: 'Подписка не найдена',
};
}
const isLoginSuccess = 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: 'Список доменов пуст!' };
}
await this.rotateSubscription(sub, domains);
this.logger.debug(`Ручная ротация подписки ${subscriptionId} завершена.`);
return { success: true, message: 'Ротация успешно выполнена' };
}
}