388 lines
13 KiB
TypeScript
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: 'Ротация успешно выполнена' };
|
|
}
|
|
}
|