add: nodes
This commit is contained in:
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user