diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 59b5160..08e8eba 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -22,6 +22,8 @@ import { ClientModule } from './client/client.module'; import { TunnelsModule } from './tunnels/tunnels.module'; 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'; @Module({ imports: [ @@ -41,7 +43,7 @@ import { SessionModule } from './session/session.module'; username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, - entities: [Setting, Domain, Subscription, Inbound, Tunnel], + entities: [Setting, Domain, Subscription, Inbound, Tunnel, Node], synchronize: true, }), SessionModule, @@ -54,6 +56,7 @@ import { SessionModule } from './session/session.module'; AuthModule, ClientModule, TunnelsModule, + NodesModule, ], controllers: [AppController], providers: [ diff --git a/server/src/inbounds/entities/inbound.entity.ts b/server/src/inbounds/entities/inbound.entity.ts index 8f42474..1e188bd 100644 --- a/server/src/inbounds/entities/inbound.entity.ts +++ b/server/src/inbounds/entities/inbound.entity.ts @@ -1,5 +1,7 @@ import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; import { Subscription } from '../../subscriptions/entities/subscription.entity'; +import { Node } from '../../nodes/entities/node.entity'; +import { Tunnel } from '../../tunnels/entities/tunnel.entity'; @Entity() export class Inbound { @@ -23,4 +25,19 @@ export class Inbound { @ManyToOne(() => Subscription, (sub) => sub.inbounds, { onDelete: 'CASCADE' }) subscription: Subscription; + + @Column({ nullable: true }) + nodeId?: string; + + @ManyToOne(() => Node, (node) => node.inbounds, { + nullable: true, + onDelete: 'SET NULL', + }) + node?: Node; + + @Column({ nullable: true }) + relayServerId?: number; + + @ManyToOne(() => Tunnel, { nullable: true, onDelete: 'SET NULL' }) + relayServer?: Tunnel; } diff --git a/server/src/inbounds/inbound-builder.service.ts b/server/src/inbounds/inbound-builder.service.ts index 9159ba9..51ddaec 100644 --- a/server/src/inbounds/inbound-builder.service.ts +++ b/server/src/inbounds/inbound-builder.service.ts @@ -422,6 +422,45 @@ export class InboundBuilderService { }; } + buildHysteria2Inbound(params: { port: number; uuid: string; sni: string }) { + const { port, uuid, sni } = params; + return { + enable: true, + port, + protocol: 'hysteria2', + remark: 'hysteria2-udp', + settings: JSON.stringify({ + clients: [ + { + password: uuid, + email: uuid, + enable: true, + limitIp: 0, + totalGB: 0, + expiryTime: 0, + reset: 0, + }, + ], + masquerade: `${sni}:443`, + }), + streamSettings: JSON.stringify({ + network: 'udp', + security: 'tls', + tlsSettings: { + serverName: sni, + alpn: ['h3'], + }, + }), + sniffing: JSON.stringify({ + enabled: false, + destOverride: ['http', 'tls', 'quic'], + metadataOnly: false, + routeOnly: false, + }), + }; + } + + generateUuid() { return uuidv4(); } @@ -448,6 +487,9 @@ export class InboundBuilderService { case 'trojan': link = this.buildTrojanLink(inbound, sni, idOrPass); break; + case 'hysteria2': + link = this.buildHysteria2PanelLink(inbound, sni, idOrPass, flagEmoji); + break; } return link; @@ -595,6 +637,25 @@ export class InboundBuilderService { ); } + private buildHysteria2PanelLink( + inbound: XuiInboundRaw, + serverAddress: string, + password: string, + flagEmoji: string, + ) { + const stream = JSON.parse(inbound.streamSettings) as { + tlsSettings?: { serverName?: string }; + }; + const params = new URLSearchParams(); + params.set('insecure', '0'); + params.set('sni', stream.tlsSettings?.serverName || serverAddress); + + return ( + `hy2://${password}@${serverAddress}:${inbound.port}/?${params.toString()}` + + `#${flagEmoji}%20${encodeURIComponent(inbound.remark || '')}` + ); + } + buildHysteria2Link( serverAddress: string, sni: string, diff --git a/server/src/migrations/1765960000000-add-nodes-and-node-relations.ts b/server/src/migrations/1765960000000-add-nodes-and-node-relations.ts new file mode 100644 index 0000000..eaeebc5 --- /dev/null +++ b/server/src/migrations/1765960000000-add-nodes-and-node-relations.ts @@ -0,0 +1,126 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNodesAndNodeRelations1765960000000 + implements MigrationInterface +{ + name = 'AddNodesAndNodeRelations1765960000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'node_protocol_enum') THEN + CREATE TYPE "node_protocol_enum" AS ENUM ('http', 'https'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'node_authtype_enum') THEN + CREATE TYPE "node_authtype_enum" AS ENUM ('password', 'token'); + END IF; + END + $$; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "node" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying NOT NULL, + "url" character varying, + "host" character varying, + "port" integer, + "protocol" "node_protocol_enum" DEFAULT 'https', + "authType" "node_authtype_enum" NOT NULL DEFAULT 'password', + "login" character varying, + "password" character varying, + "token" character varying, + "isMain" boolean NOT NULL DEFAULT false, + "version" character varying, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_node_id" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query(` + ALTER TABLE "node" + ADD COLUMN IF NOT EXISTS "url" character varying + `); + + await queryRunner.query(` + ALTER TABLE "node" + ALTER COLUMN "host" DROP NOT NULL, + ALTER COLUMN "port" DROP NOT NULL, + ALTER COLUMN "protocol" DROP NOT NULL + `).catch(() => undefined); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_node_single_main" + ON "node" ("isMain") + WHERE "isMain" = true + `); + + await queryRunner.query(` + ALTER TABLE "subscription" + ADD COLUMN IF NOT EXISTS "nodeId" uuid, + ADD COLUMN IF NOT EXISTS "relayServerId" integer + `); + + await queryRunner.query(` + ALTER TABLE "inbound" + ADD COLUMN IF NOT EXISTS "nodeId" uuid, + ADD COLUMN IF NOT EXISTS "relayServerId" integer + `); + + await queryRunner.query(` + ALTER TABLE "tunnel" + ADD COLUMN IF NOT EXISTS "nodeId" uuid, + ADD COLUMN IF NOT EXISTS "ports" text + `); + + await queryRunner.query(` + ALTER TABLE "subscription" + ADD CONSTRAINT "FK_subscription_node" + FOREIGN KEY ("nodeId") REFERENCES "node"("id") ON DELETE SET NULL + `).catch(() => undefined); + + await queryRunner.query(` + ALTER TABLE "subscription" + ADD CONSTRAINT "FK_subscription_relay" + FOREIGN KEY ("relayServerId") REFERENCES "tunnel"("id") ON DELETE SET NULL + `).catch(() => undefined); + + await queryRunner.query(` + ALTER TABLE "inbound" + ADD CONSTRAINT "FK_inbound_node" + FOREIGN KEY ("nodeId") REFERENCES "node"("id") ON DELETE SET NULL + `).catch(() => undefined); + + await queryRunner.query(` + ALTER TABLE "inbound" + ADD CONSTRAINT "FK_inbound_relay" + FOREIGN KEY ("relayServerId") REFERENCES "tunnel"("id") ON DELETE SET NULL + `).catch(() => undefined); + + await queryRunner.query(` + ALTER TABLE "tunnel" + ADD CONSTRAINT "FK_tunnel_node" + FOREIGN KEY ("nodeId") REFERENCES "node"("id") ON DELETE SET NULL + `).catch(() => undefined); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tunnel" DROP CONSTRAINT IF EXISTS "FK_tunnel_node"`); + await queryRunner.query(`ALTER TABLE "inbound" DROP CONSTRAINT IF EXISTS "FK_inbound_relay"`); + await queryRunner.query(`ALTER TABLE "inbound" DROP CONSTRAINT IF EXISTS "FK_inbound_node"`); + await queryRunner.query(`ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "FK_subscription_relay"`); + await queryRunner.query(`ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "FK_subscription_node"`); + await queryRunner.query(`ALTER TABLE "tunnel" DROP COLUMN IF EXISTS "ports"`); + await queryRunner.query(`ALTER TABLE "tunnel" DROP COLUMN IF EXISTS "nodeId"`); + await queryRunner.query(`ALTER TABLE "inbound" DROP COLUMN IF EXISTS "relayServerId"`); + await queryRunner.query(`ALTER TABLE "inbound" DROP COLUMN IF EXISTS "nodeId"`); + await queryRunner.query(`ALTER TABLE "subscription" DROP COLUMN IF EXISTS "relayServerId"`); + await queryRunner.query(`ALTER TABLE "subscription" DROP COLUMN IF EXISTS "nodeId"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_node_single_main"`); + await queryRunner.query(`DROP TABLE IF EXISTS "node"`); + await queryRunner.query(`DROP TYPE IF EXISTS "node_authtype_enum"`); + await queryRunner.query(`DROP TYPE IF EXISTS "node_protocol_enum"`); + } +} diff --git a/server/src/nodes/dto/node.dto.ts b/server/src/nodes/dto/node.dto.ts new file mode 100644 index 0000000..125d657 --- /dev/null +++ b/server/src/nodes/dto/node.dto.ts @@ -0,0 +1,49 @@ +import { + IsBoolean, + IsEnum, + IsOptional, + IsString, + MinLength, + ValidateIf, +} from 'class-validator'; +import { PartialType } from '@nestjs/mapped-types'; +import { NodeAuthType } from '../entities/node.entity'; + +export class CreateNodeDto { + @IsString() + @MinLength(2) + name: string; + + @IsString() + url: string; + + @IsEnum(NodeAuthType) + authType: NodeAuthType; + + @ValidateIf((dto: CreateNodeDto) => dto.authType === NodeAuthType.Password) + @IsString() + login?: string; + + @ValidateIf((dto: CreateNodeDto) => dto.authType === NodeAuthType.Password) + @IsString() + password?: string; + + @ValidateIf((dto: CreateNodeDto) => dto.authType === NodeAuthType.Token) + @IsString() + token?: string; + + @IsBoolean() + @IsOptional() + isMain?: boolean; + + @IsString() + @IsOptional() + version?: string; +} + +export class UpdateNodeDto extends PartialType(CreateNodeDto) {} + +export class NodeConnectionDto { + @IsString() + id: string; +} diff --git a/server/src/nodes/entities/node.entity.ts b/server/src/nodes/entities/node.entity.ts new file mode 100644 index 0000000..2398405 --- /dev/null +++ b/server/src/nodes/entities/node.entity.ts @@ -0,0 +1,80 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Inbound } from '../../inbounds/entities/inbound.entity'; +import { Subscription } from '../../subscriptions/entities/subscription.entity'; +import { Tunnel } from '../../tunnels/entities/tunnel.entity'; + +export enum NodeAuthType { + Password = 'password', + Token = 'token', +} + +export enum NodeProtocol { + Http = 'http', + Https = 'https', +} + +@Entity() +export class Node { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ nullable: true }) + url?: string; + + @Column({ nullable: true }) + host?: string; + + @Column({ type: 'int', nullable: true }) + port?: number; + + @Column({ + type: 'enum', + enum: NodeProtocol, + default: NodeProtocol.Https, + nullable: true, + }) + protocol?: NodeProtocol; + + @Column({ type: 'enum', enum: NodeAuthType, default: NodeAuthType.Password }) + authType: NodeAuthType; + + @Column({ nullable: true }) + login?: string; + + @Column({ select: false, nullable: true }) + password?: string; + + @Column({ select: false, nullable: true }) + token?: string; + + @Column({ default: false }) + isMain: boolean; + + @Column({ nullable: true }) + version?: string; + + @OneToMany(() => Subscription, (subscription) => subscription.node) + subscriptions: Subscription[]; + + @OneToMany(() => Inbound, (inbound) => inbound.node) + inbounds: Inbound[]; + + @OneToMany(() => Tunnel, (tunnel) => tunnel.node) + tunnels: Tunnel[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/server/src/nodes/nodes.controller.ts b/server/src/nodes/nodes.controller.ts new file mode 100644 index 0000000..0068df3 --- /dev/null +++ b/server/src/nodes/nodes.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'; +import { CreateNodeDto, UpdateNodeDto } from './dto/node.dto'; +import { NodesService } from './nodes.service'; + +@Controller('nodes') +export class NodesController { + constructor(private readonly nodesService: NodesService) {} + + @Get() + findAll() { + return this.nodesService.findAll(); + } + + @Post() + create(@Body() dto: CreateNodeDto) { + return this.nodesService.create(dto); + } + + @Post('check') + checkPayload(@Body() dto: CreateNodeDto) { + return this.nodesService.checkPayload(dto); + } + + @Put(':id') + update(@Param('id') id: string, @Body() dto: UpdateNodeDto) { + return this.nodesService.update(id, dto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.nodesService.remove(id); + } + + @Post(':id/main') + setMain(@Param('id') id: string) { + return this.nodesService.setMain(id); + } + + @Post(':id/check') + check(@Param('id') id: string) { + return this.nodesService.checkConnection(id); + } + + @Post('sync/main') + syncFromMain() { + return this.nodesService.syncFromMain(); + } +} diff --git a/server/src/nodes/nodes.module.ts b/server/src/nodes/nodes.module.ts new file mode 100644 index 0000000..6412510 --- /dev/null +++ b/server/src/nodes/nodes.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Node } from './entities/node.entity'; +import { NodesController } from './nodes.controller'; +import { NodesService } from './nodes.service'; +import { XuiModule } from '../xui/xui.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Node]), XuiModule], + controllers: [NodesController], + providers: [NodesService], + exports: [NodesService, TypeOrmModule], +}) +export class NodesModule {} diff --git a/server/src/nodes/nodes.service.ts b/server/src/nodes/nodes.service.ts new file mode 100644 index 0000000..a087522 --- /dev/null +++ b/server/src/nodes/nodes.service.ts @@ -0,0 +1,223 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateNodeDto, UpdateNodeDto } from './dto/node.dto'; +import { Node, NodeAuthType, NodeProtocol } from './entities/node.entity'; +import { XuiService } from '../xui/xui.service'; + +@Injectable() +export class NodesService { + constructor( + @InjectRepository(Node) + private readonly nodesRepo: Repository, + private readonly xuiService: XuiService, + ) {} + + findAll() { + return this.nodesRepo.find({ order: { isMain: 'DESC', createdAt: 'DESC' } }); + } + + async findOneWithSecrets(id: string) { + const node = await this.nodesRepo + .createQueryBuilder('node') + .addSelect('node.password') + .addSelect('node.token') + .where('node.id = :id', { id }) + .getOne(); + + if (!node) { + throw new NotFoundException('Node not found'); + } + + return node; + } + + async getDefaultNode() { + return this.nodesRepo + .createQueryBuilder('node') + .addSelect('node.password') + .addSelect('node.token') + .where('node.isMain = :isMain', { isMain: true }) + .getOne(); + } + + async create(dto: CreateNodeDto) { + this.assertCredentials(dto); + + const node = this.nodesRepo.create({ + ...dto, + url: this.normalizeUrl(dto.url), + isMain: dto.isMain ?? false, + }); + + if ((await this.nodesRepo.count()) === 0) { + node.isMain = true; + } + + if (node.isMain) { + await this.clearMainNode(); + } + + return this.nodesRepo.save(node); + } + + async update(id: string, dto: UpdateNodeDto) { + const node = await this.findOneWithSecrets(id); + const nextAuthType = dto.authType ?? node.authType; + + if (nextAuthType === NodeAuthType.Password) { + const login = dto.login ?? node.login; + const password = dto.password ?? node.password; + if (!login || !password) { + throw new BadRequestException('Login and password are required'); + } + } + + if (nextAuthType === NodeAuthType.Token) { + const token = dto.token ?? node.token; + if (!token) { + throw new BadRequestException('Token is required'); + } + } + + Object.assign(node, dto); + if (dto.url) { + node.url = this.normalizeUrl(dto.url); + } + + if (dto.isMain) { + await this.clearMainNode(id); + node.isMain = true; + } + + return this.nodesRepo.save(node); + } + + async remove(id: string) { + const node = await this.findOneWithSecrets(id); + if (node.isMain && (await this.nodesRepo.count()) > 1) { + throw new BadRequestException('Select another main node before deleting'); + } + + await this.nodesRepo.remove(node); + const main = await this.getDefaultNode(); + if (!main) { + const fallback = await this.nodesRepo.findOne({ where: {} }); + if (fallback) { + fallback.isMain = true; + await this.nodesRepo.save(fallback); + } + } + + return { success: true }; + } + + async setMain(id: string) { + const node = await this.findOneWithSecrets(id); + await this.clearMainNode(id); + node.isMain = true; + return this.nodesRepo.save(node); + } + + async checkConnection(id: string) { + const node = await this.findOneWithSecrets(id); + const status = await this.xuiService.checkNodeConnection(node); + return { success: status.success, version: status.version }; + } + + async syncFromMain() { + const main = await this.getDefaultNode(); + if (!main) { + throw new BadRequestException('Main node is not configured'); + } + + const discovered = await this.xuiService.getNodes(main); + const synced: Node[] = []; + + for (const item of discovered) { + if (!item.host || !item.port) { + continue; + } + const url = `${item.protocol}://${item.host}:${item.port}`.replace( + /\/+$/, + '', + ); + + const existing = await this.nodesRepo.findOne({ + where: { url }, + }); + + if (existing) { + existing.name = item.name || existing.name; + existing.version = item.version || existing.version; + synced.push(await this.nodesRepo.save(existing)); + continue; + } + + synced.push( + await this.nodesRepo.save( + this.nodesRepo.create({ + name: item.name || item.host, + url, + host: item.host, + port: item.port, + protocol: + item.protocol === NodeProtocol.Http + ? NodeProtocol.Http + : NodeProtocol.Https, + authType: main.authType, + login: main.login, + password: main.password, + token: main.token, + version: item.version, + isMain: false, + }), + ), + ); + } + + return { success: true, count: synced.length, nodes: synced }; + } + + private assertCredentials(dto: CreateNodeDto) { + if (dto.authType === NodeAuthType.Password && (!dto.login || !dto.password)) { + throw new BadRequestException('Login and password are required'); + } + + if (dto.authType === NodeAuthType.Token && !dto.token) { + throw new BadRequestException('Token is required'); + } + } + + private async clearMainNode(exceptId?: string) { + const qb = this.nodesRepo + .createQueryBuilder() + .update(Node) + .set({ isMain: false }) + .where('isMain = :isMain', { isMain: true }); + + if (exceptId) { + qb.andWhere('id != :exceptId', { exceptId }); + } + + await qb.execute(); + } + + async checkPayload(dto: CreateNodeDto) { + this.assertCredentials(dto); + const node = this.nodesRepo.create({ + ...dto, + url: this.normalizeUrl(dto.url), + }); + const status = await this.xuiService.checkNodeConnection(node); + return { success: status.success, version: status.version }; + } + + private normalizeUrl(url: string) { + return url.trim().replace(/\/+$/, ''); + } +} diff --git a/server/src/rotation/rotation.module.ts b/server/src/rotation/rotation.module.ts index 2111fb4..3a97965 100644 --- a/server/src/rotation/rotation.module.ts +++ b/server/src/rotation/rotation.module.ts @@ -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, diff --git a/server/src/rotation/rotation.service.ts b/server/src/rotation/rotation.service.ts index b24491d..2fbb818 100644 --- a/server/src/rotation/rotation.service.ts +++ b/server/src/rotation/rotation.service.ts @@ -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, @InjectRepository(Domain) private domainRepo: Repository, @InjectRepository(Setting) private settingRepo: Repository, + @InjectRepository(Node) private nodeRepo: Repository, + @InjectRepository(Tunnel) private tunnelRepo: Repository, 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(); 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; + } + } } diff --git a/server/src/subscriptions/dto/create-subscription.dto.ts b/server/src/subscriptions/dto/create-subscription.dto.ts index d8f81c3..5d07a99 100644 --- a/server/src/subscriptions/dto/create-subscription.dto.ts +++ b/server/src/subscriptions/dto/create-subscription.dto.ts @@ -4,6 +4,9 @@ import { ValidateNested, IsOptional, IsBoolean, + IsUUID, + IsInt, + ValidateIf, ArrayMinSize, ArrayMaxSize, } from 'class-validator'; @@ -23,6 +26,15 @@ export class InboundConfigDto { @IsString() @IsOptional() link?: string; + + @IsUUID() + @IsOptional() + nodeId?: string; + + @ValidateIf((dto: InboundConfigDto) => dto.relayServerId !== undefined) + @Type(() => Number) + @IsInt() + relayServerId?: number; } export class CreateSubscriptionDto { @@ -40,4 +52,13 @@ export class CreateSubscriptionDto { @IsBoolean() @IsOptional() isAutoRotationEnabled?: boolean; + + @IsUUID() + @IsOptional() + nodeId?: string; + + @ValidateIf((dto: CreateSubscriptionDto) => dto.relayServerId !== undefined) + @Type(() => Number) + @IsInt() + relayServerId?: number; } diff --git a/server/src/subscriptions/entities/subscription.entity.ts b/server/src/subscriptions/entities/subscription.entity.ts index 846d6d5..0e51a8a 100644 --- a/server/src/subscriptions/entities/subscription.entity.ts +++ b/server/src/subscriptions/entities/subscription.entity.ts @@ -5,8 +5,11 @@ import { CreateDateColumn, UpdateDateColumn, OneToMany, + ManyToOne, } from 'typeorm'; import { Inbound } from '../../inbounds/entities/inbound.entity'; +import { Node } from '../../nodes/entities/node.entity'; +import { Tunnel } from '../../tunnels/entities/tunnel.entity'; @Entity() export class Subscription { @@ -31,8 +34,25 @@ export class Subscription { port?: number | string; sni?: string; link?: string; + nodeId?: string; + relayServerId?: number; }>; + @Column({ nullable: true }) + nodeId?: string; + + @ManyToOne(() => Node, (node) => node.subscriptions, { + nullable: true, + onDelete: 'SET NULL', + }) + node?: Node; + + @Column({ nullable: true }) + relayServerId?: number; + + @ManyToOne(() => Tunnel, { nullable: true, onDelete: 'SET NULL' }) + relayServer?: Tunnel; + @OneToMany(() => Inbound, (inbound) => inbound.subscription) inbounds: Inbound[]; diff --git a/server/src/subscriptions/subscriptions.module.ts b/server/src/subscriptions/subscriptions.module.ts index ab24e10..a933768 100644 --- a/server/src/subscriptions/subscriptions.module.ts +++ b/server/src/subscriptions/subscriptions.module.ts @@ -5,9 +5,14 @@ import { SubscriptionsController } from './subscriptions.controller'; import { Subscription } from './entities/subscription.entity'; import { Inbound } from '../inbounds/entities/inbound.entity'; import { XuiModule } from '../xui/xui.module'; +import { Node } from '../nodes/entities/node.entity'; +import { Tunnel } from '../tunnels/entities/tunnel.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Subscription, Inbound]), XuiModule], + imports: [ + TypeOrmModule.forFeature([Subscription, Inbound, Node, Tunnel]), + XuiModule, + ], controllers: [SubscriptionsController], providers: [SubscriptionsService], exports: [SubscriptionsService], diff --git a/server/src/subscriptions/subscriptions.service.ts b/server/src/subscriptions/subscriptions.service.ts index a95b39e..532e5aa 100644 --- a/server/src/subscriptions/subscriptions.service.ts +++ b/server/src/subscriptions/subscriptions.service.ts @@ -6,18 +6,24 @@ import { XuiService } from '../xui/xui.service'; import { CreateSubscriptionDto } from './dto/create-subscription.dto'; import { UpdateSubscriptionDto } from './dto/update-subscription.dto'; import { v4 as uuidv4 } from 'uuid'; +import { Node } from '../nodes/entities/node.entity'; +import { Tunnel } from '../tunnels/entities/tunnel.entity'; @Injectable() export class SubscriptionsService { constructor( @InjectRepository(Subscription) private subRepo: Repository, + @InjectRepository(Node) + private nodeRepo: Repository, + @InjectRepository(Tunnel) + private tunnelRepo: Repository, private xuiService: XuiService, ) {} findAll() { return this.subRepo.find({ - relations: ['inbounds'], + relations: ['inbounds', 'node', 'relayServer'], order: { createdAt: 'DESC' }, }); } @@ -28,6 +34,8 @@ export class SubscriptionsService { uuid: uuidv4(), inboundsConfig: dto.inboundsConfig || [], isAutoRotationEnabled: dto.isAutoRotationEnabled ?? true, + node: await this.resolveNode(dto.nodeId), + relayServer: await this.resolveRelay(dto.relayServerId), }); return this.subRepo.save(sub); @@ -36,7 +44,7 @@ export class SubscriptionsService { async update(id: string, dto: UpdateSubscriptionDto) { const sub = await this.subRepo.findOne({ where: { id }, - relations: ['inbounds'], + relations: ['inbounds', 'node', 'relayServer'], }); if (!sub) { @@ -56,22 +64,47 @@ export class SubscriptionsService { sub.isAutoRotationEnabled = dto.isAutoRotationEnabled; } + if ('nodeId' in dto) { + sub.node = await this.resolveNode(dto.nodeId); + sub.nodeId = dto.nodeId; + } + + if ('relayServerId' in dto) { + sub.relayServer = await this.resolveRelay(dto.relayServerId); + sub.relayServerId = dto.relayServerId; + } + return this.subRepo.save(sub); } async remove(id: string) { const sub = await this.subRepo.findOne({ where: { id }, - relations: ['inbounds'], + relations: ['inbounds', 'inbounds.node'], }); if (!sub) return; if (sub.inbounds && sub.inbounds.length > 0) { for (const inbound of sub.inbounds) { - await this.xuiService.deleteInbound(inbound.xuiId); + await this.xuiService.deleteInbound(inbound.xuiId, inbound.node); } } return this.subRepo.remove(sub); } + + private async resolveNode(nodeId?: string | null) { + if (!nodeId) return null; + return this.nodeRepo + .createQueryBuilder('node') + .addSelect('node.password') + .addSelect('node.token') + .where('node.id = :nodeId', { nodeId }) + .getOne(); + } + + private async resolveRelay(relayServerId?: number | null) { + if (!relayServerId) return null; + return this.tunnelRepo.findOne({ where: { id: relayServerId } }); + } } diff --git a/server/src/tunnels/dto/create-tunnel.dto.ts b/server/src/tunnels/dto/create-tunnel.dto.ts new file mode 100644 index 0000000..c28bda2 --- /dev/null +++ b/server/src/tunnels/dto/create-tunnel.dto.ts @@ -0,0 +1,43 @@ +import { + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateTunnelDto { + @IsString() + @MinLength(2) + name: string; + + @IsUUID() + @IsOptional() + nodeId?: string; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(65535) + sshPort: number; + + @IsString() + @MinLength(1) + username: string; + + @ValidateIf((dto: CreateTunnelDto) => !dto.privateKey) + @IsString() + password?: string; + + @ValidateIf((dto: CreateTunnelDto) => !dto.password) + @IsString() + privateKey?: string; + + @IsString() + @IsOptional() + domain?: string; +} diff --git a/server/src/tunnels/entities/tunnel.entity.ts b/server/src/tunnels/entities/tunnel.entity.ts index 61d0c41..5787d2d 100644 --- a/server/src/tunnels/entities/tunnel.entity.ts +++ b/server/src/tunnels/entities/tunnel.entity.ts @@ -1,4 +1,5 @@ -import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; +import { Node } from '../../nodes/entities/node.entity'; @Entity() export class Tunnel { @@ -26,6 +27,18 @@ export class Tunnel { @Column({ nullable: true }) domain: string; + @Column({ nullable: true }) + nodeId?: string; + + @ManyToOne(() => Node, (node) => node.tunnels, { + nullable: true, + onDelete: 'SET NULL', + }) + node?: Node; + + @Column({ type: 'simple-array', nullable: true }) + ports?: number[]; + @Column({ default: false }) isInstalled: boolean; } diff --git a/server/src/tunnels/ssh.service.ts b/server/src/tunnels/ssh.service.ts index 10d537d..81f20fd 100644 --- a/server/src/tunnels/ssh.service.ts +++ b/server/src/tunnels/ssh.service.ts @@ -14,18 +14,30 @@ export class SshService { privateKey?: string; }, command: string, + timeoutMs = 120000, ): Promise { return new Promise((resolve, reject) => { const conn = new Client(); + let timer: NodeJS.Timeout | undefined; + + const finish = (callback: () => void) => { + if (timer) clearTimeout(timer); + conn.end(); + callback(); + }; conn .on('ready', () => { this.logger.debug(`SSH Connection established to ${config.host}`); + this.logger.debug(`Executing SSH command: ${command}`); + + timer = setTimeout(() => { + finish(() => reject(new Error(`SSH command timeout after ${timeoutMs}ms`))); + }, timeoutMs); conn.exec(command, (err, stream) => { if (err) { - conn.end(); - return reject(err); + return finish(() => reject(err)); } let output = ''; @@ -33,9 +45,10 @@ export class SshService { stream .on('close', (code, _signal) => { this.logger.debug(`SSH Command finished with code ${code}`); - conn.end(); - if (code === 0) resolve(output); - else reject(new Error(`Exit code ${code}. Output: ${output}`)); + finish(() => { + if (code === 0) resolve(output); + else reject(new Error(`Exit code ${code}. Output: ${output}`)); + }); }) .on('data', (data: Buffer) => { output += data.toString(); @@ -47,6 +60,7 @@ export class SshService { }) .on('error', (err) => { this.logger.error(`SSH Error: ${err.message}`); + if (timer) clearTimeout(timer); reject(err); }) .connect({ diff --git a/server/src/tunnels/tunnels.controller.ts b/server/src/tunnels/tunnels.controller.ts index bf25475..5fbd9bb 100644 --- a/server/src/tunnels/tunnels.controller.ts +++ b/server/src/tunnels/tunnels.controller.ts @@ -1,13 +1,13 @@ -import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common'; +import { Controller, Get, Post, Body, Param, Delete, Query } from '@nestjs/common'; import { TunnelsService } from './tunnels.service'; -import { Tunnel } from './entities/tunnel.entity'; +import { CreateTunnelDto } from './dto/create-tunnel.dto'; @Controller('tunnels') export class TunnelsController { constructor(private readonly tunnelsService: TunnelsService) {} @Post() - create(@Body() createTunnelDto: Tunnel) { + create(@Body() createTunnelDto: CreateTunnelDto) { return this.tunnelsService.create(createTunnelDto); } @@ -21,8 +21,16 @@ export class TunnelsController { return this.tunnelsService.installScript(+id); } + @Post(':id/uninstall') + uninstall(@Param('id') id: string) { + return this.tunnelsService.uninstallScript(+id); + } + @Delete(':id') - remove(@Param('id') id: string) { - return this.tunnelsService.remove(+id); + remove( + @Param('id') id: string, + @Query('deleteForwarding') deleteForwarding?: string, + ) { + return this.tunnelsService.remove(+id, deleteForwarding === 'true'); } } diff --git a/server/src/tunnels/tunnels.module.ts b/server/src/tunnels/tunnels.module.ts index 4b4dcb7..691faa9 100644 --- a/server/src/tunnels/tunnels.module.ts +++ b/server/src/tunnels/tunnels.module.ts @@ -5,9 +5,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Tunnel } from './entities/tunnel.entity'; import { Setting } from '../settings/entities/setting.entity'; import { SshService } from './ssh.service'; +import { Node } from '../nodes/entities/node.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Tunnel, Setting])], + imports: [TypeOrmModule.forFeature([Tunnel, Setting, Node])], controllers: [TunnelsController], providers: [TunnelsService, SshService], }) diff --git a/server/src/tunnels/tunnels.service.ts b/server/src/tunnels/tunnels.service.ts index 1ba1a2f..8e6f766 100644 --- a/server/src/tunnels/tunnels.service.ts +++ b/server/src/tunnels/tunnels.service.ts @@ -1,9 +1,11 @@ import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DeepPartial } from 'typeorm'; +import { Repository } from 'typeorm'; import { Tunnel } from './entities/tunnel.entity'; import { SshService } from './ssh.service'; import { Setting } from '../settings/entities/setting.entity'; +import { Node } from '../nodes/entities/node.entity'; +import { CreateTunnelDto } from './dto/create-tunnel.dto'; @Injectable() export class TunnelsService { @@ -12,19 +14,45 @@ export class TunnelsService { constructor( @InjectRepository(Tunnel) private tunnelRepo: Repository, @InjectRepository(Setting) private settingRepo: Repository, + @InjectRepository(Node) private nodeRepo: Repository, private sshService: SshService, ) {} - async create(createTunnelDto: DeepPartial) { - const tunnel = this.tunnelRepo.create(createTunnelDto); + async create(createTunnelDto: CreateTunnelDto) { + const node = createTunnelDto.nodeId + ? await this.nodeRepo.findOne({ where: { id: createTunnelDto.nodeId } }) + : await this.nodeRepo.findOne({ where: { isMain: true } }); + + if (!node) { + throw new HttpException('Node not found', HttpStatus.BAD_REQUEST); + } + + const ip = this.getNodeAddress(node); + if (!ip) { + throw new HttpException( + 'Cannot determine relay IP from node URL', + HttpStatus.BAD_REQUEST, + ); + } + + const tunnel = this.tunnelRepo.create({ + ...createTunnelDto, + ip, + node, + nodeId: node.id, + }); return this.tunnelRepo.save(tunnel); } async findAll() { - return this.tunnelRepo.find(); + return this.tunnelRepo.find({ relations: ['node'] }); } - async remove(id: number) { + async remove(id: number, deleteForwarding = false) { + if (deleteForwarding) { + await this.uninstallScript(id); + } + return this.tunnelRepo.delete(id); } @@ -39,17 +67,19 @@ export class TunnelsService { if (!tunnel) throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND); - const hostSetting = await this.settingRepo.findOne({ - where: { key: 'xui_ip' }, - }); + const targetNode = tunnel.nodeId + ? await this.nodeRepo.findOne({ where: { id: tunnel.nodeId } }) + : await this.nodeRepo.findOne({ where: { isMain: true } }); + const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_ip' } }); - if (!hostSetting || !hostSetting.value) { + if (!targetNode && (!hostSetting || !hostSetting.value)) { throw new HttpException( 'В настройках (Settings) не сохранен Host/IP основного сервера (xui_host). Сохраните настройки подключения к 3x-ui заново.', HttpStatus.BAD_REQUEST, ); } - const mainServerIp = hostSetting.value; + const mainServerIp = + targetNode?.host || this.getNodeAddress(targetNode) || hostSetting.value; this.logger.debug( `Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`, @@ -67,6 +97,7 @@ export class TunnelsService { privateKey: tunnel.privateKey, }, command, + 180000, ); this.logger.debug(`Скрипт выполнен успешно:\n${output}`); @@ -84,4 +115,55 @@ export class TunnelsService { ); } } + + async uninstallScript(id: number) { + const tunnel = await this.tunnelRepo + .createQueryBuilder('tunnel') + .addSelect('tunnel.password') + .addSelect('tunnel.privateKey') + .where('tunnel.id = :id', { id }) + .getOne(); + + if (!tunnel) { + throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND); + } + + const command = + 'sudo bash -c "$(curl -sSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_delete.sh)"'; + + try { + const output = await this.sshService.executeCommand( + { + host: tunnel.ip, + port: tunnel.sshPort, + username: tunnel.username, + password: tunnel.password, + privateKey: tunnel.privateKey, + }, + command, + 180000, + ); + + tunnel.isInstalled = false; + await this.tunnelRepo.save(tunnel); + return { success: true, output }; + } catch (e) { + const error = e as Error; + this.logger.error(`Forwarding delete error: ${error.message}`); + throw new HttpException( + `Forwarding delete failed: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private getNodeAddress(node?: Node | null) { + if (!node?.url) return undefined; + + try { + return new URL(node.url).hostname; + } catch { + return node.url; + } + } } diff --git a/server/src/xui/xui.service.ts b/server/src/xui/xui.service.ts index bef5104..2dcfc7e 100644 --- a/server/src/xui/xui.service.ts +++ b/server/src/xui/xui.service.ts @@ -4,8 +4,14 @@ import { Repository } from 'typeorm'; import axios, { AxiosInstance, AxiosError } from 'axios'; import * as https from 'https'; import { Setting } from '../settings/entities/setting.entity'; -import { XuiResponse, XuiCertResult, XuiInboundRaw } from './xui.types'; +import { + XuiResponse, + XuiCertResult, + XuiInboundRaw, + XuiDiscoveredNode, +} from './xui.types'; import { SessionService } from '../session/session.service'; +import { Node, NodeAuthType } from '../nodes/entities/node.entity'; interface LoginResponse { success: boolean; @@ -36,6 +42,14 @@ export class XuiService { }); } + private getNodeBaseUrl(node: Node): string { + if (node.url) { + return node.url.replace(/\/+$/, ''); + } + + return `${node.protocol}://${node.host}:${node.port}`.replace(/\/+$/, ''); + } + private async getSettings() { const settings = await this.settingsRepo.find(); const config: Record = {}; @@ -43,6 +57,56 @@ export class XuiService { return config; } + private createApi(baseURL?: string): AxiosInstance { + return axios.create({ + baseURL, + timeout: 15000, + httpsAgent: new https.Agent({ rejectUnauthorized: false }), + withCredentials: true, + }); + } + + private async createAuthenticatedApi(node?: Node): Promise { + if (!node) { + const success = await this.login(); + return success ? this.api : null; + } + + const api = this.createApi(this.getNodeBaseUrl(node)); + + if (node.authType === NodeAuthType.Token) { + if (!node.token) return null; + api.defaults.headers.common.Authorization = `Bearer ${node.token}`; + return api; + } + + if (!node.login || !node.password) return null; + + const res = await api.post('/login', { + username: node.login, + password: node.password, + }); + + if (!res.data?.success || !res.headers['set-cookie']) { + return null; + } + + api.defaults.headers.common.Cookie = res.headers['set-cookie'].join('; '); + return api; + } + + private parseVersion(headers: Record, data: unknown): string | undefined { + const headerVersion = headers['x-ui-version'] || headers['x-3x-ui-version']; + if (typeof headerVersion === 'string') return headerVersion; + + if (data && typeof data === 'object' && 'version' in data) { + const version = (data as { version?: unknown }).version; + return typeof version === 'string' ? version : undefined; + } + + return undefined; + } + async login() { try { const config = await this.getSettings(); @@ -79,6 +143,7 @@ export class XuiService { async addInbound( inboundConfig: { port: number; [key: string]: unknown } | XuiInboundRaw, + node?: Node, ): Promise { let attempts = 0; const maxAttempts = 3; @@ -89,7 +154,13 @@ export class XuiService { attempts++; try { - const res = await this.api.post>( + const api = await this.createAuthenticatedApi(node); + if (!api) { + this.logger.error('3x-ui authentication failed before addInbound'); + return null; + } + + const res = await api.post>( '/panel/api/inbounds/add', inboundConfig, ); @@ -122,7 +193,7 @@ export class XuiService { const error = e as AxiosError; if (error.response?.status === 401) { this.logger.log('Сессия истекла, пробуем релогин...'); - if (await this.login()) { + if (!node && (await this.login())) { return this.addInbound(inboundConfig); } } @@ -140,9 +211,11 @@ export class XuiService { return null; } - async deleteInbound(id: number) { + async deleteInbound(id: number, node?: Node) { try { - await this.api.post(`/panel/api/inbounds/del/${id}`); + const api = await this.createAuthenticatedApi(node); + if (!api) return; + await api.post(`/panel/api/inbounds/del/${id}`); this.logger.debug(`Инбаунд ${id} удален`); } catch (e) { const error = e as AxiosError; @@ -187,9 +260,32 @@ export class XuiService { return false; } - async getNewX25519Cert(): Promise { + async checkNodeConnection( + node: Node, + ): Promise<{ success: boolean; version?: string }> { try { - const res = await this.api.get>( + const api = await this.createAuthenticatedApi(node); + if (!api) return { success: false }; + + const res = await api.get('/panel/api/inbounds/list'); + return { + success: true, + version: this.parseVersion(res.headers as Record, res.data), + }; + } catch (error) { + const axiosError = error as AxiosError; + this.logger.error( + `Node connection error: ${axiosError.message} (${node.name})`, + ); + return { success: false }; + } + } + + async getNewX25519Cert(node?: Node): Promise { + try { + const api = await this.createAuthenticatedApi(node); + if (!api) return null; + const res = await api.get>( '/panel/api/server/getNewX25519Cert', ); if (res.data?.success && res.data.obj) return res.data.obj; @@ -198,4 +294,24 @@ export class XuiService { } return null; } + + async getNodes(node: Node): Promise { + try { + const api = await this.createAuthenticatedApi(node); + if (!api) return []; + + const res = await api.get>( + '/panel/api/nodes/list', + ); + + if (res.data?.success && Array.isArray(res.data.obj)) { + return res.data.obj; + } + } catch (error) { + const axiosError = error as AxiosError; + this.logger.warn(`Node sync is not available: ${axiosError.message}`); + } + + return []; + } } diff --git a/server/src/xui/xui.types.ts b/server/src/xui/xui.types.ts index eff22b8..4b4527c 100644 --- a/server/src/xui/xui.types.ts +++ b/server/src/xui/xui.types.ts @@ -63,3 +63,11 @@ export interface XuiCertResult { privateKey: string; publicKey: string; } + +export interface XuiDiscoveredNode { + name?: string; + host: string; + port: number; + protocol: 'http' | 'https'; + version?: string; +}