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
+4 -1
View File
@@ -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: [
@@ -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;
}
@@ -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,
@@ -0,0 +1,126 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddNodesAndNodeRelations1765960000000
implements MigrationInterface
{
name = 'AddNodesAndNodeRelations1765960000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
+49
View File
@@ -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;
}
+80
View File
@@ -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;
}
+48
View File
@@ -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();
}
}
+14
View File
@@ -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 {}
+223
View File
@@ -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<Node>,
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(/\/+$/, '');
}
}
+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;
}
}
}
@@ -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;
}
@@ -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[];
@@ -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],
@@ -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<Subscription>,
@InjectRepository(Node)
private nodeRepo: Repository<Node>,
@InjectRepository(Tunnel)
private tunnelRepo: Repository<Tunnel>,
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 } });
}
}
@@ -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;
}
+14 -1
View File
@@ -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;
}
+19 -5
View File
@@ -14,18 +14,30 @@ export class SshService {
privateKey?: string;
},
command: string,
timeoutMs = 120000,
): Promise<string> {
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({
+13 -5
View File
@@ -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');
}
}
+2 -1
View File
@@ -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],
})
+92 -10
View File
@@ -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<Tunnel>,
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
@InjectRepository(Node) private nodeRepo: Repository<Node>,
private sshService: SshService,
) {}
async create(createTunnelDto: DeepPartial<Tunnel>) {
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;
}
}
}
+123 -7
View File
@@ -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<string, string> = {};
@@ -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<AxiosInstance | null> {
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<LoginResponse>('/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<string, unknown>, 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<number | null> {
let attempts = 0;
const maxAttempts = 3;
@@ -89,7 +154,13 @@ export class XuiService {
attempts++;
try {
const res = await this.api.post<XuiResponse<{ id: number }>>(
const api = await this.createAuthenticatedApi(node);
if (!api) {
this.logger.error('3x-ui authentication failed before addInbound');
return null;
}
const res = await api.post<XuiResponse<{ id: number }>>(
'/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<XuiCertResult | null> {
async checkNodeConnection(
node: Node,
): Promise<{ success: boolean; version?: string }> {
try {
const res = await this.api.get<XuiResponse<XuiCertResult>>(
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<string, unknown>, 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<XuiCertResult | null> {
try {
const api = await this.createAuthenticatedApi(node);
if (!api) return null;
const res = await api.get<XuiResponse<XuiCertResult>>(
'/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<XuiDiscoveredNode[]> {
try {
const api = await this.createAuthenticatedApi(node);
if (!api) return [];
const res = await api.get<XuiResponse<XuiDiscoveredNode[]>>(
'/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 [];
}
}
+8
View File
@@ -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;
}