add: nodes

This commit is contained in:
Den Piligrim
2026-05-18 15:50:36 +03:00
parent 9bf4d34317
commit 5629b43cd0
23 changed files with 1148 additions and 53 deletions
+3 -1
View File
@@ -11,10 +11,12 @@ import { Inbound } from '../inbounds/entities/inbound.entity';
import { Domain } from '../domains/entities/domain.entity';
import { Setting } from '../settings/entities/setting.entity';
import { RotationController } from './rotation.controller';
import { Node } from '../nodes/entities/node.entity';
import { Tunnel } from '../tunnels/entities/tunnel.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Subscription, Inbound, Domain, Setting]),
TypeOrmModule.forFeature([Subscription, Inbound, Domain, Setting, Node, Tunnel]),
ScheduleModule.forRoot(),
XuiModule,
InboundsModule,
+125 -17
View File
@@ -7,6 +7,8 @@ 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 { Node } from '../nodes/entities/node.entity';
import { Tunnel } from '../tunnels/entities/tunnel.entity';
import { XuiService } from '../xui/xui.service';
import { InboundBuilderService } from '../inbounds/inbound-builder.service';
@@ -22,6 +24,8 @@ export class RotationService implements OnModuleInit {
@InjectRepository(Inbound) private inboundRepo: Repository<Inbound>,
@InjectRepository(Domain) private domainRepo: Repository<Domain>,
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
@InjectRepository(Node) private nodeRepo: Repository<Node>,
@InjectRepository(Tunnel) private tunnelRepo: Repository<Tunnel>,
private xuiService: XuiService,
private inboundBuilder: InboundBuilderService,
) {}
@@ -127,7 +131,8 @@ export class RotationService implements OnModuleInit {
async performRotation() {
this.logger.debug('Запуск плановой ротации...');
const isLoginSuccess = await this.xuiService.login();
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' };
@@ -138,7 +143,7 @@ export class RotationService implements OnModuleInit {
isEnabled: true,
isAutoRotationEnabled: true,
},
relations: ['inbounds'],
relations: ['inbounds', 'node', 'relayServer'],
});
if (subscriptions.length === 0) {
return { success: false, message: 'Нет активных подписок для ротации' };
@@ -151,27 +156,32 @@ export class RotationService implements OnModuleInit {
}
for (const sub of subscriptions) {
await this.rotateSubscription(sub, domains);
await this.rotateSubscription(sub, domains, defaultNode);
}
this.logger.debug('Ротация завершена.');
return { success: true, message: 'Ротация успешно выполнена' };
}
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
private async rotateSubscription(
sub: Subscription,
domains: Domain[],
defaultNode: Node | null,
) {
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.xuiService.deleteInbound(inbound.xuiId, inbound.node);
}
await this.inboundRepo.delete(inbound.id);
}
}
const keys = await this.xuiService.getNewX25519Cert();
const baseNode = sub.node ?? defaultNode ?? undefined;
const keys = await this.xuiService.getNewX25519Cert(baseNode);
if (!keys) {
this.logger.error(
'Не удалось получить Reality ключи, пропускаем подписку',
@@ -181,7 +191,8 @@ export class RotationService implements OnModuleInit {
const usedPorts = new Set<number>();
const host = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
const serverAddress = host?.value || 'localhost';
const serverAddress =
this.getNodeAddress(baseNode) || host?.value || 'localhost';
const flag = await this.settingRepo.findOne({
where: { key: 'xui_geo_flag' },
});
@@ -193,6 +204,20 @@ export class RotationService implements OnModuleInit {
for (const config of inboundsConfig) {
const type = config.type;
const uuid = uuidv4();
const targetNode = await this.resolveNode(
config.nodeId,
sub.node,
defaultNode,
);
const relayServer = await this.resolveRelay(
config.relayServerId,
sub.relayServer,
);
const targetAddress =
relayServer?.domain ||
relayServer?.ip ||
this.getNodeAddress(targetNode) ||
serverAddress;
let sni = '';
@@ -214,18 +239,48 @@ export class RotationService implements OnModuleInit {
// === 2. Обработка Hysteria2 ===
if (type === 'hysteria2-udp') {
const link = this.inboundBuilder.buildHysteria2Link(
serverAddress,
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 hysteriaConfig = this.inboundBuilder.buildHysteria2Inbound({
port,
uuid,
sni,
flagEmoji + '%20hysteria2-udp',
});
const xuiId = await this.xuiService.addInbound(
hysteriaConfig,
targetNode,
);
const link =
xuiId && hysteriaConfig
? this.inboundBuilder.buildInboundLink(
hysteriaConfig,
targetAddress,
uuid,
flagEmoji,
)
: this.inboundBuilder.buildHysteria2Link(
targetAddress,
sni,
flagEmoji + '%20hysteria2-udp',
);
const newInbound = this.inboundRepo.create({
xuiId: 0,
port: 0, // Обычно Hysteria висит на 443, фактический порт вытаскивается в билдере
xuiId: xuiId || 0,
port,
protocol: 'hysteria2',
remark: 'hysteria2-udp',
link: link,
subscription: sub,
node: targetNode,
relayServer,
});
await this.inboundRepo.save(newInbound);
continue;
@@ -295,7 +350,7 @@ export class RotationService implements OnModuleInit {
continue;
}
const xuiId = await this.xuiService.addInbound(xuiConfig);
const xuiId = await this.xuiService.addInbound(xuiConfig, targetNode);
if (xuiId && xuiConfig) {
const settings = JSON.parse(xuiConfig.settings) as {
@@ -306,7 +361,7 @@ export class RotationService implements OnModuleInit {
const fullLink = this.inboundBuilder.buildInboundLink(
xuiConfig,
serverAddress,
targetAddress,
idOrPass,
flagEmoji,
);
@@ -318,6 +373,8 @@ export class RotationService implements OnModuleInit {
remark: xuiConfig.remark,
link: fullLink,
subscription: sub,
node: targetNode,
relayServer,
});
await this.inboundRepo.save(newInbound);
}
@@ -356,7 +413,7 @@ export class RotationService implements OnModuleInit {
const sub = await this.subRepo.findOne({
where: { id: subscriptionId },
relations: ['inbounds'],
relations: ['inbounds', 'node', 'relayServer'],
});
if (!sub) {
@@ -367,7 +424,8 @@ export class RotationService implements OnModuleInit {
};
}
const isLoginSuccess = await this.xuiService.login();
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' };
@@ -379,9 +437,59 @@ export class RotationService implements OnModuleInit {
return { success: false, message: 'Список доменов пуст!' };
}
await this.rotateSubscription(sub, domains);
await this.rotateSubscription(sub, domains, defaultNode);
this.logger.debug(`Ручная ротация подписки ${subscriptionId} завершена.`);
return { success: true, message: 'Ротация успешно выполнена' };
}
private async getDefaultNode() {
return this.nodeRepo
.createQueryBuilder('node')
.addSelect('node.password')
.addSelect('node.token')
.where('node.isMain = :isMain', { isMain: true })
.getOne();
}
private async resolveNode(
nodeId?: string,
subscriptionNode?: Node,
defaultNode?: Node | null,
) {
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
);
}
private async resolveRelay(relayServerId?: number, subscriptionRelay?: Tunnel) {
if (!relayServerId) return subscriptionRelay ?? undefined;
return (
(await this.tunnelRepo.findOne({ where: { id: relayServerId } })) ??
subscriptionRelay ??
undefined
);
}
private getNodeAddress(node?: Node) {
if (!node) return undefined;
if (node.host) return node.host;
try {
return node.url ? new URL(node.url).hostname : undefined;
} catch {
return node.url;
}
}
}