v2.2.0
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { InboundBuilderService } from '../src/inbounds/inbound-builder.service';
|
||||
import { XuiInboundRaw } from '../src/inbounds/xui-inbound.types';
|
||||
import { Node } from '../src/nodes/entities/node.entity';
|
||||
import { XuiService } from '../src/xui/xui.service';
|
||||
|
||||
const SNI = process.env.SMOKE_SNI || 'www.cloudflare.com';
|
||||
|
||||
type SmokeResult = {
|
||||
type: string;
|
||||
port: number;
|
||||
success: boolean;
|
||||
xuiId?: number | null;
|
||||
deleted?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const randomPort = () => Math.floor(Math.random() * (60000 - 20000 + 1)) + 20000;
|
||||
|
||||
async function main() {
|
||||
console.log('Starting inbound smoke test...');
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
console.log('Application context is ready.');
|
||||
|
||||
const nodeRepo = app.get<Repository<Node>>(getRepositoryToken(Node));
|
||||
const xuiService = app.get(XuiService);
|
||||
const builder = app.get(InboundBuilderService);
|
||||
|
||||
const node = await nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.isMain = :isMain', { isMain: true })
|
||||
.getOne();
|
||||
|
||||
if (!node) {
|
||||
throw new Error('Main node not found');
|
||||
}
|
||||
console.log(`Main node loaded: ${node.name}`);
|
||||
|
||||
const keys = await xuiService.getNewX25519Cert(node);
|
||||
if (!keys) {
|
||||
throw new Error('Could not get Reality keys from the main node');
|
||||
}
|
||||
console.log('Reality keys received.');
|
||||
|
||||
const buildCases: Array<{
|
||||
type: string;
|
||||
build: (port: number, uuid: string) => XuiInboundRaw;
|
||||
}> = [
|
||||
{
|
||||
type: 'vless-tcp-reality',
|
||||
build: (port, uuid) => builder.buildVlessRealityTcp({ port, uuid, sni: SNI, ...keys }),
|
||||
},
|
||||
{
|
||||
type: 'vless-xhttp-reality',
|
||||
build: (port, uuid) => builder.buildVlessRealityXhttp({ port, uuid, sni: SNI, ...keys }),
|
||||
},
|
||||
{
|
||||
type: 'vless-grpc-reality',
|
||||
build: (port, uuid) => builder.buildVlessRealityGrpc({ port, uuid, sni: SNI, ...keys }),
|
||||
},
|
||||
{
|
||||
type: 'vless-ws',
|
||||
build: (port, uuid) => builder.buildVlessWs({ port, uuid, sni: SNI }),
|
||||
},
|
||||
{
|
||||
type: 'vmess-tcp',
|
||||
build: (port, uuid) => builder.buildVmessTcp({ port, uuid }),
|
||||
},
|
||||
{
|
||||
type: 'shadowsocks-tcp',
|
||||
build: (port, uuid) => builder.buildShadowsocksTcp({ port, uuid }),
|
||||
},
|
||||
{
|
||||
type: 'trojan-tcp-reality',
|
||||
build: (port, uuid) => builder.buildTrojanRealityTcp({ port, uuid, sni: SNI, ...keys }),
|
||||
},
|
||||
{
|
||||
type: 'hysteria2-udp',
|
||||
build: (port, uuid) => builder.buildHysteria2Inbound({ port, uuid, sni: SNI }),
|
||||
},
|
||||
];
|
||||
|
||||
const results: SmokeResult[] = [];
|
||||
|
||||
for (const testCase of buildCases) {
|
||||
const port = randomPort();
|
||||
const uuid = uuidv4();
|
||||
let xuiId: number | null = null;
|
||||
|
||||
try {
|
||||
console.log(`Testing ${testCase.type} on port ${port}...`);
|
||||
const config = testCase.build(port, uuid);
|
||||
config.remark = `smoke-${testCase.type}-${Date.now()}`;
|
||||
xuiId = await xuiService.addInbound(config, node);
|
||||
|
||||
if (xuiId) {
|
||||
console.log(`${testCase.type}: created with xuiId=${xuiId}; deleting...`);
|
||||
await xuiService.deleteInbound(xuiId, node);
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: testCase.type,
|
||||
port,
|
||||
success: Boolean(xuiId),
|
||||
xuiId,
|
||||
deleted: Boolean(xuiId),
|
||||
});
|
||||
console.log(`${testCase.type}: ${xuiId ? 'ok' : 'failed'}`);
|
||||
} catch (error) {
|
||||
if (xuiId) {
|
||||
await xuiService.deleteInbound(xuiId, node);
|
||||
}
|
||||
results.push({
|
||||
type: testCase.type,
|
||||
port,
|
||||
success: false,
|
||||
xuiId,
|
||||
deleted: Boolean(xuiId),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.table(results);
|
||||
await app.close();
|
||||
|
||||
const failed = results.filter((result) => !result.success);
|
||||
process.exitCode = failed.length > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -422,38 +422,87 @@ export class InboundBuilderService {
|
||||
};
|
||||
}
|
||||
|
||||
buildHysteria2Inbound(params: { port: number; uuid: string; sni: string }) {
|
||||
buildHysteria2Inbound(params: {
|
||||
port: number;
|
||||
uuid: string;
|
||||
sni: string;
|
||||
certificateFile?: string;
|
||||
keyFile?: string;
|
||||
}) {
|
||||
const { port, uuid, sni } = params;
|
||||
const certificateFile =
|
||||
params.certificateFile || `/root/cert/${sni}/fullchain.pem`;
|
||||
const keyFile =
|
||||
params.keyFile || `/root/cert/${sni}/privkey.pem`;
|
||||
const obfsPassword = crypto.randomBytes(8).toString('hex');
|
||||
return {
|
||||
enable: true,
|
||||
port,
|
||||
protocol: 'hysteria2',
|
||||
protocol: 'hysteria',
|
||||
remark: 'hysteria2-udp',
|
||||
settings: JSON.stringify({
|
||||
clients: [
|
||||
{
|
||||
password: uuid,
|
||||
auth: uuid,
|
||||
email: uuid,
|
||||
enable: true,
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0,
|
||||
reset: 0,
|
||||
},
|
||||
],
|
||||
masquerade: `${sni}:443`,
|
||||
version: 2,
|
||||
}),
|
||||
streamSettings: JSON.stringify({
|
||||
network: 'udp',
|
||||
network: 'hysteria',
|
||||
security: 'tls',
|
||||
finalmask: {
|
||||
udp: [
|
||||
{
|
||||
settings: {
|
||||
password: obfsPassword,
|
||||
},
|
||||
type: 'salamander',
|
||||
},
|
||||
],
|
||||
},
|
||||
hysteriaSettings: {
|
||||
auth: uuid,
|
||||
masquerade: {
|
||||
content: '',
|
||||
dir: '',
|
||||
headers: {},
|
||||
insecure: true,
|
||||
rewriteHost: false,
|
||||
statusCode: 0,
|
||||
type: 'proxy',
|
||||
url: 'https://google.com',
|
||||
},
|
||||
udpIdleTimeout: 60,
|
||||
version: 2,
|
||||
},
|
||||
tlsSettings: {
|
||||
serverName: sni,
|
||||
alpn: ['h3'],
|
||||
certificates: [
|
||||
{
|
||||
buildChain: false,
|
||||
certificateFile,
|
||||
keyFile,
|
||||
oneTimeLoading: false,
|
||||
usage: 'encipherment',
|
||||
},
|
||||
],
|
||||
cipherSuites: '',
|
||||
disableSystemRoot: false,
|
||||
echForceQuery: 'none',
|
||||
echServerKeys: '',
|
||||
enableSessionResumption: false,
|
||||
maxVersion: '1.3',
|
||||
minVersion: '1.2',
|
||||
rejectUnknownSni: false,
|
||||
},
|
||||
}),
|
||||
sniffing: JSON.stringify({
|
||||
enabled: false,
|
||||
destOverride: ['http', 'tls', 'quic'],
|
||||
destOverride: ['http', 'tls', 'quic', 'fakedns'],
|
||||
metadataOnly: false,
|
||||
routeOnly: false,
|
||||
}),
|
||||
@@ -487,6 +536,7 @@ export class InboundBuilderService {
|
||||
case 'trojan':
|
||||
link = this.buildTrojanLink(inbound, sni, idOrPass);
|
||||
break;
|
||||
case 'hysteria':
|
||||
case 'hysteria2':
|
||||
link = this.buildHysteria2PanelLink(inbound, sni, idOrPass, flagEmoji);
|
||||
break;
|
||||
@@ -645,13 +695,38 @@ export class InboundBuilderService {
|
||||
) {
|
||||
const stream = JSON.parse(inbound.streamSettings) as {
|
||||
tlsSettings?: { serverName?: string };
|
||||
finalmask?: { udp?: Array<{ type?: string; settings?: { password?: string } }> };
|
||||
};
|
||||
const settings = JSON.parse(inbound.settings) as {
|
||||
clients?: Array<{ auth?: string; password?: string }>;
|
||||
};
|
||||
const auth = settings.clients?.[0]?.auth || settings.clients?.[0]?.password || password;
|
||||
const finalmask = stream.finalmask?.udp?.[0];
|
||||
const params = new URLSearchParams();
|
||||
params.set('insecure', '0');
|
||||
params.set('insecure', '1');
|
||||
params.set('security', 'tls');
|
||||
params.set('fp', 'chrome');
|
||||
params.set('alpn', 'h3');
|
||||
|
||||
const fmConfig = {
|
||||
udp: [
|
||||
{
|
||||
type: finalmask.type,
|
||||
settings: {
|
||||
password: finalmask.settings.password,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
params.set('fm', JSON.stringify(fmConfig));
|
||||
params.set('sni', stream.tlsSettings?.serverName || serverAddress);
|
||||
if (finalmask?.type) params.set('obfs', finalmask.type);
|
||||
if (finalmask?.settings?.password) {
|
||||
params.set('obfs-password', finalmask.settings.password);
|
||||
}
|
||||
|
||||
return (
|
||||
`hy2://${password}@${serverAddress}:${inbound.port}/?${params.toString()}` +
|
||||
`hy2://${auth}@${serverAddress}:${inbound.port}/?${params.toString()}` +
|
||||
`#${flagEmoji}%20${encodeURIComponent(inbound.remark || '')}`
|
||||
);
|
||||
}
|
||||
@@ -694,11 +769,28 @@ export class InboundBuilderService {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('insecure', '0');
|
||||
params.set('insecure', '1');
|
||||
params.set('security', 'tls');
|
||||
params.set('fp', 'chrome');
|
||||
params.set('alpn', 'h3');
|
||||
|
||||
params.set('sni', serverAddress);
|
||||
|
||||
params.set('obfs', obfs);
|
||||
params.set('obfs-password', obfsPass);
|
||||
|
||||
const fmConfig = {
|
||||
udp: [
|
||||
{
|
||||
type: obfs,
|
||||
settings: {
|
||||
password: obfsPass,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
params.set('fm', JSON.stringify(fmConfig));
|
||||
|
||||
return `hy2://${auth}@${serverAddress}:${port}/?${params.toString()}#${remark}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddNodeIpFlagAndInboundLabels1770000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddNodeIpFlagAndInboundLabels1770000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "node"
|
||||
ADD COLUMN IF NOT EXISTS "ip" character varying,
|
||||
ADD COLUMN IF NOT EXISTS "flag" character varying
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "node" DROP COLUMN IF EXISTS "flag"`);
|
||||
await queryRunner.query(`ALTER TABLE "node" DROP COLUMN IF EXISTS "ip"`);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsIP,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
@@ -17,6 +18,18 @@ export class CreateNodeDto {
|
||||
@IsString()
|
||||
url: string;
|
||||
|
||||
@IsIP()
|
||||
@IsOptional()
|
||||
ip?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
domain?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
flag?: string;
|
||||
|
||||
@IsEnum(NodeAuthType)
|
||||
authType: NodeAuthType;
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@ export class Node {
|
||||
@Column({ nullable: true })
|
||||
host?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
domain?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
ip?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
flag?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
port?: number;
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ export class NodesController {
|
||||
return this.nodesService.checkPayload(dto);
|
||||
}
|
||||
|
||||
@Post('detect-location')
|
||||
detectLocation(@Body() body: { url: string }) {
|
||||
return this.nodesService.detectLocation(body.url);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateNodeDto) {
|
||||
return this.nodesService.update(id, dto);
|
||||
|
||||
@@ -4,9 +4,15 @@ import { Node } from './entities/node.entity';
|
||||
import { NodesController } from './nodes.controller';
|
||||
import { NodesService } from './nodes.service';
|
||||
import { XuiModule } from '../xui/xui.module';
|
||||
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||
import { Tunnel } from '../tunnels/entities/tunnel.entity';
|
||||
import { Inbound } from '../inbounds/entities/inbound.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Node]), XuiModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Node, Subscription, Tunnel, Inbound]),
|
||||
XuiModule,
|
||||
],
|
||||
controllers: [NodesController],
|
||||
providers: [NodesService],
|
||||
exports: [NodesService, TypeOrmModule],
|
||||
|
||||
@@ -8,12 +8,34 @@ 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';
|
||||
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||
import { Tunnel } from '../tunnels/entities/tunnel.entity';
|
||||
import { Inbound } from '../inbounds/entities/inbound.entity';
|
||||
import * as dns from 'dns/promises';
|
||||
import * as net from 'net';
|
||||
import { COUNTRIES } from '../settings/countries';
|
||||
|
||||
type GeoResult = {
|
||||
ip: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
flag?: string;
|
||||
};
|
||||
|
||||
const getDomainFromHost = (host?: string) =>
|
||||
host && net.isIP(host) === 0 ? host : undefined;
|
||||
|
||||
@Injectable()
|
||||
export class NodesService {
|
||||
constructor(
|
||||
@InjectRepository(Node)
|
||||
private readonly nodesRepo: Repository<Node>,
|
||||
@InjectRepository(Subscription)
|
||||
private readonly subscriptionsRepo: Repository<Subscription>,
|
||||
@InjectRepository(Tunnel)
|
||||
private readonly tunnelsRepo: Repository<Tunnel>,
|
||||
@InjectRepository(Inbound)
|
||||
private readonly inboundsRepo: Repository<Inbound>,
|
||||
private readonly xuiService: XuiService,
|
||||
) {}
|
||||
|
||||
@@ -47,10 +69,17 @@ export class NodesService {
|
||||
|
||||
async create(dto: CreateNodeDto) {
|
||||
this.assertCredentials(dto);
|
||||
const resolved = await this.resolveNodeLocation(dto.url, dto.flag, dto.ip);
|
||||
|
||||
const node = this.nodesRepo.create({
|
||||
...dto,
|
||||
url: this.normalizeUrl(dto.url),
|
||||
host: resolved.host,
|
||||
domain: dto.domain || resolved.domain,
|
||||
port: resolved.port,
|
||||
protocol: resolved.protocol,
|
||||
ip: resolved.ip,
|
||||
flag: resolved.flag,
|
||||
isMain: dto.isMain ?? false,
|
||||
});
|
||||
|
||||
@@ -87,6 +116,21 @@ export class NodesService {
|
||||
Object.assign(node, dto);
|
||||
if (dto.url) {
|
||||
node.url = this.normalizeUrl(dto.url);
|
||||
const resolved = await this.resolveNodeLocation(
|
||||
dto.url,
|
||||
dto.flag ?? node.flag,
|
||||
dto.ip,
|
||||
);
|
||||
node.host = resolved.host;
|
||||
node.domain = dto.domain || resolved.domain;
|
||||
node.port = resolved.port;
|
||||
node.protocol = resolved.protocol;
|
||||
node.ip = resolved.ip;
|
||||
node.flag = resolved.flag;
|
||||
} else {
|
||||
if (dto.domain !== undefined) node.domain = dto.domain;
|
||||
if (dto.ip) node.ip = dto.ip;
|
||||
if (dto.flag) node.flag = dto.flag;
|
||||
}
|
||||
|
||||
if (dto.isMain) {
|
||||
@@ -99,14 +143,17 @@ export class NodesService {
|
||||
|
||||
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');
|
||||
}
|
||||
const nodeCount = await this.nodesRepo.count();
|
||||
|
||||
await this.cleanupNodeDependencies(node, nodeCount === 1);
|
||||
await this.nodesRepo.remove(node);
|
||||
|
||||
const main = await this.getDefaultNode();
|
||||
if (!main) {
|
||||
const fallback = await this.nodesRepo.findOne({ where: {} });
|
||||
const fallback = await this.nodesRepo.findOne({
|
||||
where: {},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
if (fallback) {
|
||||
fallback.isMain = true;
|
||||
await this.nodesRepo.save(fallback);
|
||||
@@ -116,6 +163,65 @@ export class NodesService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async cleanupNodeDependencies(node: Node, isLastNode: boolean) {
|
||||
await this.deleteNodeInbounds(node);
|
||||
const id = node.id;
|
||||
|
||||
if (isLastNode) {
|
||||
await this.subscriptionsRepo.createQueryBuilder().delete().execute();
|
||||
await this.tunnelsRepo.createQueryBuilder().delete().execute();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.tunnelsRepo.delete({ nodeId: id });
|
||||
await this.inboundsRepo.delete({ nodeId: id });
|
||||
|
||||
const subscriptions = await this.subscriptionsRepo.find({
|
||||
where: [{ nodeId: id }],
|
||||
});
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
sub.nodeId = undefined;
|
||||
sub.node = undefined;
|
||||
await this.subscriptionsRepo.save(sub);
|
||||
}
|
||||
|
||||
const configuredSubscriptions = await this.subscriptionsRepo.find();
|
||||
for (const sub of configuredSubscriptions) {
|
||||
const config = sub.inboundsConfig || [];
|
||||
const nextConfig = config.map((item) => {
|
||||
if (item.nodeId !== id) return item;
|
||||
const { nodeId: _nodeId, relayServerId: _relayServerId, ...rest } = item;
|
||||
return rest;
|
||||
});
|
||||
|
||||
if (JSON.stringify(nextConfig) !== JSON.stringify(config)) {
|
||||
sub.inboundsConfig = nextConfig;
|
||||
await this.subscriptionsRepo.save(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteNodeInbounds(node: Node) {
|
||||
const inbounds = await this.inboundsRepo.find({
|
||||
where: { nodeId: node.id },
|
||||
});
|
||||
|
||||
for (const inbound of inbounds) {
|
||||
if (inbound.xuiId && inbound.xuiId > 0) {
|
||||
const isDeleted = await this.xuiService.deleteInbound(
|
||||
inbound.xuiId,
|
||||
node,
|
||||
);
|
||||
if (!isDeleted) {
|
||||
throw new BadRequestException(
|
||||
`Failed to delete inbound ${inbound.xuiId} from 3x-ui`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setMain(id: string) {
|
||||
const node = await this.findOneWithSecrets(id);
|
||||
await this.clearMainNode(id);
|
||||
@@ -164,7 +270,12 @@ export class NodesService {
|
||||
name: item.name || item.host,
|
||||
url,
|
||||
host: item.host,
|
||||
domain: getDomainFromHost(item.host),
|
||||
port: item.port,
|
||||
ip: await this.resolveIp(item.host),
|
||||
flag: (
|
||||
await this.lookupGeo(await this.resolveIp(item.host))
|
||||
)?.flag,
|
||||
protocol:
|
||||
item.protocol === NodeProtocol.Http
|
||||
? NodeProtocol.Http
|
||||
@@ -217,6 +328,107 @@ export class NodesService {
|
||||
return { success: status.success, version: status.version };
|
||||
}
|
||||
|
||||
async detectLocation(url: string) {
|
||||
const resolved = await this.resolveNodeLocation(url);
|
||||
return {
|
||||
ip: resolved.ip,
|
||||
host: resolved.host,
|
||||
domain: resolved.domain,
|
||||
port: resolved.port,
|
||||
protocol: resolved.protocol,
|
||||
flag: resolved.flag,
|
||||
country: resolved.country,
|
||||
countryCode: resolved.countryCode,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveNodeLocation(
|
||||
url: string,
|
||||
preferredFlag?: string,
|
||||
preferredIp?: string,
|
||||
) {
|
||||
const normalized = this.normalizeUrl(url);
|
||||
const parsed = this.parseUrl(normalized);
|
||||
const ip = preferredIp || (await this.resolveIp(parsed.host));
|
||||
const geo = ip ? await this.lookupGeo(ip) : undefined;
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
domain: getDomainFromHost(parsed.host),
|
||||
ip,
|
||||
country: geo?.country,
|
||||
countryCode: geo?.countryCode,
|
||||
flag: preferredFlag || geo?.flag,
|
||||
};
|
||||
}
|
||||
|
||||
private parseUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
protocol:
|
||||
parsed.protocol.replace(':', '') === NodeProtocol.Http
|
||||
? NodeProtocol.Http
|
||||
: NodeProtocol.Https,
|
||||
};
|
||||
} catch {
|
||||
return { host: url, port: undefined, protocol: NodeProtocol.Https };
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveIp(host?: string) {
|
||||
if (!host || host === 'localhost') return undefined;
|
||||
if (net.isIP(host) !== 0) return host;
|
||||
|
||||
try {
|
||||
const result = await dns.lookup(host);
|
||||
return result.address;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async lookupGeo(ip?: string): Promise<GeoResult | undefined> {
|
||||
if (!ip || ip === '127.0.0.1') return undefined;
|
||||
|
||||
const fromCode = (countryCode?: string, country?: string) => {
|
||||
const countryInfo = COUNTRIES.find((c) => c.code === countryCode);
|
||||
return countryInfo
|
||||
? { ip, country: countryInfo.name, countryCode, flag: countryInfo.emoji }
|
||||
: { ip, country, countryCode };
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://ipwho.is/${ip}`);
|
||||
const data = (await res.json()) as {
|
||||
success?: boolean;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
};
|
||||
if (data.success !== false) {
|
||||
return fromCode(data.country_code, data.country);
|
||||
}
|
||||
} catch {
|
||||
// Fallback below.
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://ip-api.com/json/${ip}`);
|
||||
const data = (await res.json()) as {
|
||||
status?: string;
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
};
|
||||
if (data.status === 'success') {
|
||||
return fromCode(data.countryCode, data.country);
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeUrl(url: string) {
|
||||
return url.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export class RotationService implements OnModuleInit {
|
||||
isEnabled: true,
|
||||
isAutoRotationEnabled: true,
|
||||
},
|
||||
relations: ['inbounds', 'node', 'relayServer'],
|
||||
relations: ['inbounds', 'inbounds.node', 'node', 'relayServer'],
|
||||
});
|
||||
if (subscriptions.length === 0) {
|
||||
return { success: false, message: 'Нет активных подписок для ротации' };
|
||||
@@ -156,7 +156,13 @@ export class RotationService implements OnModuleInit {
|
||||
}
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
await this.rotateSubscription(sub, domains, defaultNode);
|
||||
const rotated = await this.rotateSubscription(sub, domains, defaultNode);
|
||||
if (!rotated) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete old inbounds',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Ротация завершена.');
|
||||
@@ -174,7 +180,16 @@ export class RotationService implements OnModuleInit {
|
||||
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, inbound.node);
|
||||
const isDeleted = await this.xuiService.deleteInbound(
|
||||
inbound.xuiId,
|
||||
await this.resolveInboundNode(inbound),
|
||||
);
|
||||
if (!isDeleted) {
|
||||
this.logger.error(
|
||||
`Failed to delete old inbound ${inbound.xuiId}; rotation for subscription ${sub.id} is aborted`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await this.inboundRepo.delete(inbound.id);
|
||||
}
|
||||
@@ -186,7 +201,7 @@ export class RotationService implements OnModuleInit {
|
||||
this.logger.error(
|
||||
'Не удалось получить Reality ключи, пропускаем подписку',
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const usedPorts = new Set<number>();
|
||||
@@ -196,7 +211,7 @@ export class RotationService implements OnModuleInit {
|
||||
const flag = await this.settingRepo.findOne({
|
||||
where: { key: 'xui_geo_flag' },
|
||||
});
|
||||
const flagEmoji = flag?.value ?? '%F0%9F%92%AF';
|
||||
const defaultFlagEmoji = flag?.value ?? '%F0%9F%92%AF';
|
||||
|
||||
// Получаем конфиг или пустой массив
|
||||
const inboundsConfig = sub.inboundsConfig || [];
|
||||
@@ -209,15 +224,20 @@ export class RotationService implements OnModuleInit {
|
||||
sub.node,
|
||||
defaultNode,
|
||||
);
|
||||
const relayServer = await this.resolveRelay(
|
||||
const resolvedRelay = await this.resolveRelay(
|
||||
config.relayServerId,
|
||||
sub.relayServer,
|
||||
);
|
||||
const relayServer =
|
||||
resolvedRelay && this.isRelayAvailableForNode(resolvedRelay, targetNode)
|
||||
? resolvedRelay
|
||||
: undefined;
|
||||
const targetAddress =
|
||||
relayServer?.domain ||
|
||||
relayServer?.ip ||
|
||||
this.getNodeAddress(targetNode) ||
|
||||
serverAddress;
|
||||
const flagEmoji = config.flag || targetNode?.flag || defaultFlagEmoji;
|
||||
|
||||
let sni = '';
|
||||
|
||||
@@ -250,33 +270,39 @@ export class RotationService implements OnModuleInit {
|
||||
}
|
||||
usedPorts.add(port);
|
||||
|
||||
const hysteriaSni = this.getNodeAddress(targetNode) || serverAddress;
|
||||
const hysteriaConfig = this.inboundBuilder.buildHysteria2Inbound({
|
||||
port,
|
||||
uuid,
|
||||
sni,
|
||||
sni: hysteriaSni,
|
||||
certificateFile: config.certificateFile,
|
||||
keyFile: config.keyFile,
|
||||
});
|
||||
if (config.name?.trim()) {
|
||||
hysteriaConfig.remark = config.name.trim();
|
||||
}
|
||||
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',
|
||||
);
|
||||
if (!xuiId) {
|
||||
this.logger.warn(
|
||||
'Hysteria2 inbound was not created by 3x-ui; skipping subscription link for this inbound',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const link = this.inboundBuilder.buildInboundLink(
|
||||
hysteriaConfig,
|
||||
targetAddress,
|
||||
uuid,
|
||||
flagEmoji,
|
||||
);
|
||||
const newInbound = this.inboundRepo.create({
|
||||
xuiId: xuiId || 0,
|
||||
xuiId,
|
||||
port,
|
||||
protocol: 'hysteria2',
|
||||
remark: 'hysteria2-udp',
|
||||
remark: hysteriaConfig.remark,
|
||||
link: link,
|
||||
subscription: sub,
|
||||
node: targetNode,
|
||||
@@ -350,6 +376,10 @@ export class RotationService implements OnModuleInit {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.name?.trim()) {
|
||||
xuiConfig.remark = config.name.trim();
|
||||
}
|
||||
|
||||
const xuiId = await this.xuiService.addInbound(xuiConfig, targetNode);
|
||||
|
||||
if (xuiId && xuiConfig) {
|
||||
@@ -379,6 +409,8 @@ export class RotationService implements OnModuleInit {
|
||||
await this.inboundRepo.save(newInbound);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private pickDomain(list: Domain[]): string {
|
||||
@@ -413,7 +445,7 @@ export class RotationService implements OnModuleInit {
|
||||
|
||||
const sub = await this.subRepo.findOne({
|
||||
where: { id: subscriptionId },
|
||||
relations: ['inbounds', 'node', 'relayServer'],
|
||||
relations: ['inbounds', 'inbounds.node', 'node', 'relayServer'],
|
||||
});
|
||||
|
||||
if (!sub) {
|
||||
@@ -437,7 +469,13 @@ export class RotationService implements OnModuleInit {
|
||||
return { success: false, message: 'Список доменов пуст!' };
|
||||
}
|
||||
|
||||
await this.rotateSubscription(sub, domains, defaultNode);
|
||||
const rotated = await this.rotateSubscription(sub, domains, defaultNode);
|
||||
if (!rotated) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete old inbounds',
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug(`Ручная ротация подписки ${subscriptionId} завершена.`);
|
||||
return { success: true, message: 'Ротация успешно выполнена' };
|
||||
@@ -472,18 +510,33 @@ export class RotationService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRelay(relayServerId?: number, subscriptionRelay?: Tunnel) {
|
||||
if (!relayServerId) return subscriptionRelay ?? undefined;
|
||||
private async resolveInboundNode(inbound: Inbound) {
|
||||
if (!inbound.nodeId) return inbound.node;
|
||||
|
||||
return (
|
||||
(await this.tunnelRepo.findOne({ where: { id: relayServerId } })) ??
|
||||
subscriptionRelay ??
|
||||
undefined
|
||||
(await this.nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.id = :nodeId', { nodeId: inbound.nodeId })
|
||||
.getOne()) ?? inbound.node
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRelay(relayServerId?: number, subscriptionRelay?: Tunnel) {
|
||||
if (!relayServerId) return subscriptionRelay ?? undefined;
|
||||
return (await this.tunnelRepo.findOne({ where: { id: relayServerId } })) ?? undefined;
|
||||
}
|
||||
|
||||
private isRelayAvailableForNode(relay: Tunnel, node?: Node) {
|
||||
if (!relay.nodeId) return true;
|
||||
return Boolean(node?.id && relay.nodeId === node.id);
|
||||
}
|
||||
|
||||
private getNodeAddress(node?: Node) {
|
||||
if (!node) return undefined;
|
||||
if (node.domain) return node.domain;
|
||||
if (node.ip) return node.ip;
|
||||
if (node.host) return node.host;
|
||||
|
||||
try {
|
||||
|
||||
@@ -26,6 +26,11 @@ export class SettingsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
countries() {
|
||||
return COUNTRIES;
|
||||
}
|
||||
|
||||
@Post('check')
|
||||
async checkConnection(
|
||||
@Body() body: { xui_url: string; xui_login: string; xui_password: string },
|
||||
|
||||
@@ -9,14 +9,45 @@ import {
|
||||
ValidateIf,
|
||||
ArrayMinSize,
|
||||
ArrayMaxSize,
|
||||
ValidateBy,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const PORT_OR_RANDOM = 'portOrRandom';
|
||||
|
||||
function IsPortOrRandom(validationOptions?: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: PORT_OR_RANDOM,
|
||||
validator: {
|
||||
validate: (value: unknown) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'random') return true;
|
||||
const port =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && /^\d+$/.test(value)
|
||||
? Number(value)
|
||||
: NaN;
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
||||
},
|
||||
defaultMessage: () =>
|
||||
'port must be "random" or an integer from 1 to 65535',
|
||||
},
|
||||
},
|
||||
validationOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export class InboundConfigDto {
|
||||
@IsString()
|
||||
type: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsPortOrRandom()
|
||||
port?: number | string;
|
||||
|
||||
@IsString()
|
||||
@@ -35,6 +66,22 @@ export class InboundConfigDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
relayServerId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
flag?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
certificateFile?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
keyFile?: string;
|
||||
}
|
||||
|
||||
export class CreateSubscriptionDto {
|
||||
|
||||
@@ -36,6 +36,10 @@ export class Subscription {
|
||||
link?: string;
|
||||
nodeId?: string;
|
||||
relayServerId?: number;
|
||||
flag?: string;
|
||||
name?: string;
|
||||
certificateFile?: string;
|
||||
keyFile?: string;
|
||||
}>;
|
||||
|
||||
@Column({ nullable: true })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Subscription } from './entities/subscription.entity';
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
import { Inbound } from '../inbounds/entities/inbound.entity';
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionsService {
|
||||
@@ -29,6 +30,7 @@ export class SubscriptionsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateSubscriptionDto) {
|
||||
await this.validateInboundsConfig(dto.inboundsConfig);
|
||||
const sub = this.subRepo.create({
|
||||
name: dto.name,
|
||||
uuid: uuidv4(),
|
||||
@@ -57,6 +59,7 @@ export class SubscriptionsService {
|
||||
}
|
||||
|
||||
if (dto.inboundsConfig) {
|
||||
await this.validateInboundsConfig(dto.inboundsConfig);
|
||||
sub.inboundsConfig = dto.inboundsConfig;
|
||||
}
|
||||
|
||||
@@ -86,7 +89,18 @@ export class SubscriptionsService {
|
||||
|
||||
if (sub.inbounds && sub.inbounds.length > 0) {
|
||||
for (const inbound of sub.inbounds) {
|
||||
await this.xuiService.deleteInbound(inbound.xuiId, inbound.node);
|
||||
if (!inbound.xuiId || inbound.xuiId <= 0) continue;
|
||||
|
||||
const isDeleted = await this.xuiService.deleteInbound(
|
||||
inbound.xuiId,
|
||||
await this.resolveInboundNode(inbound),
|
||||
);
|
||||
|
||||
if (!isDeleted) {
|
||||
throw new BadRequestException(
|
||||
`Failed to delete inbound ${inbound.xuiId} from 3x-ui`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,16 +109,97 @@ export class SubscriptionsService {
|
||||
|
||||
private async resolveNode(nodeId?: string | null) {
|
||||
if (!nodeId) return null;
|
||||
return this.nodeRepo
|
||||
const node = await this.nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.id = :nodeId', { nodeId })
|
||||
.getOne();
|
||||
|
||||
if (!node) {
|
||||
throw new BadRequestException('Node not found');
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private async resolveInboundNode(inbound: Inbound) {
|
||||
if (!inbound.nodeId) return undefined;
|
||||
|
||||
return (
|
||||
(await this.nodeRepo
|
||||
.createQueryBuilder('node')
|
||||
.addSelect('node.password')
|
||||
.addSelect('node.token')
|
||||
.where('node.id = :nodeId', { nodeId: inbound.nodeId })
|
||||
.getOne()) ?? inbound.node
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRelay(relayServerId?: number | null) {
|
||||
if (!relayServerId) return null;
|
||||
return this.tunnelRepo.findOne({ where: { id: relayServerId } });
|
||||
const relay = await this.tunnelRepo.findOne({
|
||||
where: { id: relayServerId },
|
||||
});
|
||||
|
||||
if (!relay) {
|
||||
throw new BadRequestException('Relay server not found');
|
||||
}
|
||||
|
||||
return relay;
|
||||
}
|
||||
|
||||
private async validateInboundsConfig(
|
||||
inboundsConfig?: CreateSubscriptionDto['inboundsConfig'],
|
||||
) {
|
||||
for (const config of inboundsConfig || []) {
|
||||
if (config.type === 'custom') continue;
|
||||
|
||||
if (config.nodeId) {
|
||||
const node = await this.nodeRepo.findOne({
|
||||
where: { id: config.nodeId },
|
||||
});
|
||||
if (!node) {
|
||||
throw new BadRequestException('Node not found');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.relayServerId) {
|
||||
const relay = await this.tunnelRepo.findOne({
|
||||
where: { id: config.relayServerId },
|
||||
});
|
||||
if (!relay) {
|
||||
throw new BadRequestException('Relay server not found');
|
||||
}
|
||||
|
||||
if (config.nodeId && relay.nodeId && relay.nodeId !== config.nodeId) {
|
||||
throw new BadRequestException(
|
||||
'Relay server belongs to another node',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
config.port === undefined ||
|
||||
config.port === null ||
|
||||
config.port === '' ||
|
||||
config.port === 'random'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const port =
|
||||
typeof config.port === 'number'
|
||||
? config.port
|
||||
: /^\d+$/.test(config.port)
|
||||
? Number(config.port)
|
||||
: NaN;
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new BadRequestException(
|
||||
'Port must be "random" or an integer from 1 to 65535',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ export class CreateTunnelDto {
|
||||
@IsOptional()
|
||||
nodeId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
ip: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
|
||||
@@ -6,9 +6,10 @@ 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';
|
||||
import { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Tunnel, Setting, Node])],
|
||||
imports: [TypeOrmModule.forFeature([Tunnel, Setting, Node, Subscription])],
|
||||
controllers: [TunnelsController],
|
||||
providers: [TunnelsService, SshService],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,10 @@ 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 { Subscription } from '../subscriptions/entities/subscription.entity';
|
||||
import { CreateTunnelDto } from './dto/create-tunnel.dto';
|
||||
import * as net from 'net';
|
||||
import * as dns from 'dns/promises';
|
||||
|
||||
@Injectable()
|
||||
export class TunnelsService {
|
||||
@@ -15,10 +18,14 @@ export class TunnelsService {
|
||||
@InjectRepository(Tunnel) private tunnelRepo: Repository<Tunnel>,
|
||||
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
|
||||
@InjectRepository(Node) private nodeRepo: Repository<Node>,
|
||||
@InjectRepository(Subscription)
|
||||
private subscriptionRepo: Repository<Subscription>,
|
||||
private sshService: SshService,
|
||||
) {}
|
||||
|
||||
async create(createTunnelDto: CreateTunnelDto) {
|
||||
const address = await this.resolveRelayAddress(createTunnelDto.ip);
|
||||
|
||||
const node = createTunnelDto.nodeId
|
||||
? await this.nodeRepo.findOne({ where: { id: createTunnelDto.nodeId } })
|
||||
: await this.nodeRepo.findOne({ where: { isMain: true } });
|
||||
@@ -27,20 +34,19 @@ export class TunnelsService {
|
||||
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({
|
||||
const tunnelPayload = {
|
||||
...createTunnelDto,
|
||||
ip,
|
||||
ip: address.ip,
|
||||
node,
|
||||
nodeId: node.id,
|
||||
});
|
||||
};
|
||||
|
||||
const domain = createTunnelDto.domain || address.domain;
|
||||
if (domain) {
|
||||
Object.assign(tunnelPayload, { domain });
|
||||
}
|
||||
|
||||
const tunnel = this.tunnelRepo.create(tunnelPayload);
|
||||
return this.tunnelRepo.save(tunnel);
|
||||
}
|
||||
|
||||
@@ -53,9 +59,37 @@ export class TunnelsService {
|
||||
await this.uninstallScript(id);
|
||||
}
|
||||
|
||||
await this.cleanupRelayDependencies(id);
|
||||
return this.tunnelRepo.delete(id);
|
||||
}
|
||||
|
||||
private async cleanupRelayDependencies(id: number) {
|
||||
const subscriptions = await this.subscriptionRepo.find({
|
||||
where: [{ relayServerId: id }],
|
||||
});
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
sub.relayServerId = undefined;
|
||||
sub.relayServer = undefined;
|
||||
await this.subscriptionRepo.save(sub);
|
||||
}
|
||||
|
||||
const configuredSubscriptions = await this.subscriptionRepo.find();
|
||||
for (const sub of configuredSubscriptions) {
|
||||
const config = sub.inboundsConfig || [];
|
||||
const nextConfig = config.map((item) => {
|
||||
if (item.relayServerId !== id) return item;
|
||||
const { relayServerId: _relayServerId, ...rest } = item;
|
||||
return rest;
|
||||
});
|
||||
|
||||
if (JSON.stringify(nextConfig) !== JSON.stringify(config)) {
|
||||
sub.inboundsConfig = nextConfig;
|
||||
await this.subscriptionRepo.save(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async installScript(id: number) {
|
||||
const tunnel = await this.tunnelRepo
|
||||
.createQueryBuilder('tunnel')
|
||||
@@ -79,7 +113,10 @@ export class TunnelsService {
|
||||
);
|
||||
}
|
||||
const mainServerIp =
|
||||
targetNode?.host || this.getNodeAddress(targetNode) || hostSetting.value;
|
||||
targetNode?.ip ||
|
||||
targetNode?.host ||
|
||||
this.getNodeAddress(targetNode) ||
|
||||
hostSetting?.value;
|
||||
|
||||
this.logger.debug(
|
||||
`Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`,
|
||||
@@ -166,4 +203,42 @@ export class TunnelsService {
|
||||
return node.url;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveRelayAddress(value: string) {
|
||||
const address = value.trim();
|
||||
if (!address) {
|
||||
throw new HttpException(
|
||||
'Relay server address is required',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
if (net.isIP(address) !== 0) {
|
||||
return { ip: address, domain: undefined };
|
||||
}
|
||||
|
||||
if (!this.isValidHostname(address)) {
|
||||
throw new HttpException(
|
||||
'Relay server address is invalid',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dns.lookup(address);
|
||||
return { ip: result.address, domain: address };
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
'Relay server domain cannot be resolved',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isValidHostname(value: string) {
|
||||
if (value.length > 253) return false;
|
||||
return /^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import { Setting } from '../settings/entities/setting.entity';
|
||||
import {
|
||||
XuiResponse,
|
||||
@@ -29,7 +30,7 @@ export class XuiService {
|
||||
) {
|
||||
this.api = axios.create({
|
||||
timeout: 15000,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
proxy: false,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
@@ -61,11 +62,20 @@ export class XuiService {
|
||||
return axios.create({
|
||||
baseURL,
|
||||
timeout: 15000,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
proxy: false,
|
||||
...this.getAgentConfig(baseURL),
|
||||
withCredentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
private getAgentConfig(baseURL?: string) {
|
||||
if (!baseURL || baseURL.startsWith('https://')) {
|
||||
return { httpsAgent: new https.Agent({ rejectUnauthorized: false }) };
|
||||
}
|
||||
|
||||
return { httpAgent: new http.Agent() };
|
||||
}
|
||||
|
||||
private async createAuthenticatedApi(node?: Node): Promise<AxiosInstance | null> {
|
||||
if (!node) {
|
||||
const success = await this.login();
|
||||
@@ -121,6 +131,9 @@ export class XuiService {
|
||||
|
||||
this.logger.log(`Attempting login to 3x-ui: ${config['xui_url']}`);
|
||||
this.api.defaults.baseURL = config['xui_url'];
|
||||
const agentConfig = this.getAgentConfig(config['xui_url']);
|
||||
this.api.defaults.httpAgent = agentConfig.httpAgent;
|
||||
this.api.defaults.httpsAgent = agentConfig.httpsAgent;
|
||||
|
||||
const res = await this.api.post<LoginResponse>('/login', {
|
||||
username: config['xui_login'],
|
||||
@@ -211,16 +224,35 @@ export class XuiService {
|
||||
return null;
|
||||
}
|
||||
|
||||
async deleteInbound(id: number, node?: Node) {
|
||||
async deleteInbound(id: number, node?: Node): Promise<boolean> {
|
||||
if (!id || id <= 0) {
|
||||
this.logger.debug(`Skipping 3x-ui inbound deletion for non-remote id: ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = await this.createAuthenticatedApi(node);
|
||||
if (!api) return;
|
||||
await api.post(`/panel/api/inbounds/del/${id}`);
|
||||
this.logger.debug(`Инбаунд ${id} удален`);
|
||||
if (!api) {
|
||||
this.logger.error(`3x-ui authentication failed before deleting inbound ${id}`);
|
||||
return false;
|
||||
}
|
||||
const res = await api.post<XuiResponse<unknown>>(
|
||||
`/panel/api/inbounds/del/${id}`,
|
||||
);
|
||||
if (!res.data?.success) {
|
||||
this.logger.error(
|
||||
`3x-ui rejected inbound deletion ${id}: ${res.data?.msg || 'unknown error'}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.logger.debug(`Inbound ${id} deleted`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
const error = e as AxiosError;
|
||||
this.logger.error(`Ошибка удаления инбаунда ${id}: ${error.message}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async checkConnection(
|
||||
@@ -234,7 +266,8 @@ export class XuiService {
|
||||
const tempApi = axios.create({
|
||||
baseURL: url,
|
||||
timeout: 5000,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
proxy: false,
|
||||
...this.getAgentConfig(url),
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -270,6 +270,50 @@ describe('InboundBuilderService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHysteria2Inbound', () => {
|
||||
it('creates 3x-ui hysteria v2 inbound with certificate paths', () => {
|
||||
const result = service.buildHysteria2Inbound({
|
||||
port: 34443,
|
||||
uuid: 'test-auth',
|
||||
sni: 'oil.3dp-manager.com',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
enable: true,
|
||||
listen: '0.0.0.0',
|
||||
port: 34443,
|
||||
protocol: 'hysteria',
|
||||
tag: 'inbound-34443',
|
||||
});
|
||||
|
||||
const settings = JSON.parse(result.settings);
|
||||
const streamSettings = JSON.parse(result.streamSettings);
|
||||
|
||||
expect(settings.clients[0].auth).toBe('test-auth');
|
||||
expect(settings.version).toBe(2);
|
||||
expect(streamSettings.network).toBe('hysteria');
|
||||
expect(streamSettings.hysteriaSettings.version).toBe(2);
|
||||
expect(streamSettings.finalmask.udp[0].type).toBe('salamander');
|
||||
expect(streamSettings.tlsSettings.certificates[0].certificateFile).toBe(
|
||||
'/etc/letsencrypt/live/oil.3dp-manager.com/fullchain.pem',
|
||||
);
|
||||
expect(streamSettings.tlsSettings.certificates[0].keyFile).toBe(
|
||||
'/etc/letsencrypt/live/oil.3dp-manager.com/privkey.pem',
|
||||
);
|
||||
|
||||
const link = service.buildInboundLink(
|
||||
result as any,
|
||||
'relay.example.com',
|
||||
'fallback-auth',
|
||||
'%F0%9F%92%AF',
|
||||
);
|
||||
expect(link).toContain('hy2://test-auth@relay.example.com:34443/');
|
||||
expect(link).toContain('sni=oil.3dp-manager.com');
|
||||
expect(link).toContain('obfs=salamander');
|
||||
expect(link).toContain('obfs-password=abcd1234');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInboundLink', () => {
|
||||
const baseInbound = {
|
||||
protocol: 'vless',
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { NodesService } from 'src/nodes/nodes.service';
|
||||
import { Node } from 'src/nodes/entities/node.entity';
|
||||
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
|
||||
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
||||
import { Inbound } from 'src/inbounds/entities/inbound.entity';
|
||||
import { XuiService } from 'src/xui/xui.service';
|
||||
|
||||
describe('NodesService', () => {
|
||||
const createNodeRepo = (getOne: jest.Mock) => ({
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getOne,
|
||||
})),
|
||||
count: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
});
|
||||
|
||||
const createService = (nodeRepo: ReturnType<typeof createNodeRepo>) => {
|
||||
const subscriptionsRepo = {
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({}),
|
||||
})),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const tunnelsRepo = {
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({}),
|
||||
})),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const inboundsRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const xuiService = {
|
||||
deleteInbound: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
return {
|
||||
service: new NodesService(
|
||||
nodeRepo as unknown as Repository<Node>,
|
||||
subscriptionsRepo as unknown as Repository<Subscription>,
|
||||
tunnelsRepo as unknown as Repository<Tunnel>,
|
||||
inboundsRepo as unknown as Repository<Inbound>,
|
||||
xuiService as unknown as XuiService,
|
||||
),
|
||||
subscriptionsRepo,
|
||||
tunnelsRepo,
|
||||
inboundsRepo,
|
||||
xuiService,
|
||||
};
|
||||
};
|
||||
|
||||
it('deletes the main node and makes the next node main', async () => {
|
||||
const mainNode = { id: 'main', isMain: true } as Node;
|
||||
const nextNode = { id: 'next', isMain: false } as Node;
|
||||
const getOne = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mainNode)
|
||||
.mockResolvedValueOnce(null);
|
||||
const nodeRepo = createNodeRepo(getOne);
|
||||
nodeRepo.count.mockResolvedValue(2);
|
||||
nodeRepo.findOne.mockResolvedValue(nextNode);
|
||||
nodeRepo.save.mockResolvedValue(nextNode);
|
||||
const { service } = createService(nodeRepo);
|
||||
|
||||
const result = await service.remove('main');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(nodeRepo.remove).toHaveBeenCalledWith(mainNode);
|
||||
expect(nodeRepo.findOne).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
expect(nextNode.isMain).toBe(true);
|
||||
expect(nodeRepo.save).toHaveBeenCalledWith(nextNode);
|
||||
});
|
||||
|
||||
it('uses node credentials when deleting node inbounds', async () => {
|
||||
const mainNode = { id: 'main', isMain: true } as Node;
|
||||
const getOne = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mainNode)
|
||||
.mockResolvedValueOnce(null);
|
||||
const nodeRepo = createNodeRepo(getOne);
|
||||
nodeRepo.count.mockResolvedValue(1);
|
||||
nodeRepo.findOne.mockResolvedValue(null);
|
||||
const { service, inboundsRepo, xuiService } = createService(nodeRepo);
|
||||
inboundsRepo.find.mockResolvedValue([{ id: 1, xuiId: 101, nodeId: 'main' }]);
|
||||
|
||||
await service.remove('main');
|
||||
|
||||
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101, mainNode);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import { Domain } from 'src/domains/entities/domain.entity';
|
||||
import { Setting } from 'src/settings/entities/setting.entity';
|
||||
import { XuiService } from 'src/xui/xui.service';
|
||||
import { InboundBuilderService } from 'src/inbounds/inbound-builder.service';
|
||||
import { Node } from 'src/nodes/entities/node.entity';
|
||||
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
||||
|
||||
// Mock @nestjs/schedule для тестирования Cron
|
||||
jest.mock('@nestjs/schedule', () => ({
|
||||
@@ -64,6 +66,19 @@ describe('RotationService', () => {
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockNodeRepo = {
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn(),
|
||||
})),
|
||||
};
|
||||
|
||||
const mockTunnelRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockXuiService = {
|
||||
login: jest.fn(),
|
||||
deleteInbound: jest.fn(),
|
||||
@@ -79,6 +94,7 @@ describe('RotationService', () => {
|
||||
buildVmessTcp: jest.fn(),
|
||||
buildShadowsocksTcp: jest.fn(),
|
||||
buildTrojanRealityTcp: jest.fn(),
|
||||
buildHysteria2Inbound: jest.fn(),
|
||||
buildHysteria2Link: jest.fn(),
|
||||
buildInboundLink: jest.fn(),
|
||||
};
|
||||
@@ -103,6 +119,14 @@ describe('RotationService', () => {
|
||||
provide: getRepositoryToken(Setting),
|
||||
useValue: mockSettingRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Node),
|
||||
useValue: mockNodeRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Tunnel),
|
||||
useValue: mockTunnelRepo,
|
||||
},
|
||||
{
|
||||
provide: XuiService,
|
||||
useValue: mockXuiService,
|
||||
@@ -129,6 +153,10 @@ describe('RotationService', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockXuiService.deleteInbound.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('onModuleInit', () => {
|
||||
it('должен инициализировать настройки по умолчанию', async () => {
|
||||
mockSettingRepo.findOne.mockResolvedValue(null);
|
||||
@@ -437,7 +465,7 @@ describe('RotationService', () => {
|
||||
{ id: 1, name: 'ya.ru', isEnabled: true },
|
||||
]);
|
||||
|
||||
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101);
|
||||
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101, undefined);
|
||||
expect(inboundRepo.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -468,6 +496,7 @@ describe('RotationService', () => {
|
||||
id: '1',
|
||||
uuid: 'uuid-1',
|
||||
isEnabled: true,
|
||||
node: { id: 'node-1', domain: 'node.example.com' },
|
||||
inbounds: [],
|
||||
inboundsConfig: [{ type: 'hysteria2-udp', sni: 'ya.ru' }],
|
||||
};
|
||||
@@ -475,15 +504,59 @@ describe('RotationService', () => {
|
||||
mockDomainRepo.find.mockResolvedValue([
|
||||
{ id: 1, name: 'ya.ru', isEnabled: true },
|
||||
]);
|
||||
mockInboundBuilder.buildHysteria2Link.mockReturnValue('hy2://link');
|
||||
mockXuiService.getNewX25519Cert.mockResolvedValue({
|
||||
privateKey: 'key',
|
||||
publicKey: 'pub',
|
||||
});
|
||||
mockInboundBuilder.buildHysteria2Inbound.mockReturnValue({
|
||||
protocol: 'hysteria2',
|
||||
remark: 'hysteria2-udp',
|
||||
settings: '{"clients":[{"password":"uuid"}]}',
|
||||
streamSettings: '{"tlsSettings":{"serverName":"ya.ru"}}',
|
||||
sniffing: '{}',
|
||||
});
|
||||
mockXuiService.addInbound.mockResolvedValue(101);
|
||||
mockInboundBuilder.buildInboundLink.mockReturnValue('hy2://link');
|
||||
mockInboundRepo.save.mockResolvedValue({});
|
||||
|
||||
await (service as any).rotateSubscription(mockSub, [
|
||||
{ id: 1, name: 'ya.ru', isEnabled: true },
|
||||
]);
|
||||
|
||||
expect(xuiService.addInbound).not.toHaveBeenCalled();
|
||||
expect(inboundBuilder.buildHysteria2Link).toHaveBeenCalled();
|
||||
expect(xuiService.addInbound).toHaveBeenCalled();
|
||||
expect(inboundBuilder.buildHysteria2Inbound).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sni: 'node.example.com' }),
|
||||
);
|
||||
expect(inboundBuilder.buildInboundLink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not save hysteria2 when 3x-ui does not create an inbound', async () => {
|
||||
const mockSub = {
|
||||
id: '1',
|
||||
uuid: 'uuid-1',
|
||||
isEnabled: true,
|
||||
inbounds: [],
|
||||
inboundsConfig: [{ type: 'hysteria2-udp', sni: 'ya.ru' }],
|
||||
};
|
||||
|
||||
mockXuiService.getNewX25519Cert.mockResolvedValue({
|
||||
privateKey: 'key',
|
||||
publicKey: 'pub',
|
||||
});
|
||||
mockInboundBuilder.buildHysteria2Inbound.mockReturnValue({
|
||||
protocol: 'hysteria2',
|
||||
remark: 'hysteria2-udp',
|
||||
settings: '{"clients":[{"password":"uuid"}]}',
|
||||
streamSettings: '{"tlsSettings":{"serverName":"ya.ru"}}',
|
||||
sniffing: '{}',
|
||||
});
|
||||
mockXuiService.addInbound.mockResolvedValue(null);
|
||||
|
||||
await (service as any).rotateSubscription(mockSub, [
|
||||
{ id: 1, name: 'ya.ru', isEnabled: true },
|
||||
]);
|
||||
|
||||
expect(inboundRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('должен использовать случайный порт, если указано random', async () => {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { SubscriptionsService } from 'src/subscriptions/subscriptions.service';
|
||||
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
|
||||
import { XuiService } from 'src/xui/xui.service';
|
||||
import { CreateSubscriptionDto } from 'src/subscriptions/dto/create-subscription.dto';
|
||||
import { Node } from 'src/nodes/entities/node.entity';
|
||||
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
||||
|
||||
describe('SubscriptionsService', () => {
|
||||
let service: SubscriptionsService;
|
||||
@@ -23,6 +25,19 @@ describe('SubscriptionsService', () => {
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
const mockNodeRepo = {
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn(),
|
||||
})),
|
||||
};
|
||||
|
||||
const mockTunnelRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockXuiService = {
|
||||
deleteInbound: jest.fn(),
|
||||
};
|
||||
@@ -35,6 +50,14 @@ describe('SubscriptionsService', () => {
|
||||
provide: getRepositoryToken(Subscription),
|
||||
useValue: mockSubRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Node),
|
||||
useValue: mockNodeRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Tunnel),
|
||||
useValue: mockTunnelRepo,
|
||||
},
|
||||
{
|
||||
provide: XuiService,
|
||||
useValue: mockXuiService,
|
||||
@@ -47,6 +70,7 @@ describe('SubscriptionsService', () => {
|
||||
getRepositoryToken(Subscription),
|
||||
);
|
||||
xuiService = module.get<XuiService>(XuiService);
|
||||
mockXuiService.deleteInbound.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,7 +110,7 @@ describe('SubscriptionsService', () => {
|
||||
|
||||
expect(result).toEqual(mockSubs);
|
||||
expect(subRepo.find).toHaveBeenCalledWith({
|
||||
relations: ['inbounds'],
|
||||
relations: ['inbounds', 'node', 'relayServer'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
});
|
||||
@@ -128,6 +152,8 @@ describe('SubscriptionsService', () => {
|
||||
uuid: expect.any(String),
|
||||
inboundsConfig: createDto.inboundsConfig,
|
||||
isAutoRotationEnabled: true,
|
||||
node: null,
|
||||
relayServer: null,
|
||||
});
|
||||
expect(subRepo.save).toHaveBeenCalledWith(mockSubscription);
|
||||
expect(result).toEqual(mockSubscription);
|
||||
@@ -197,7 +223,7 @@ describe('SubscriptionsService', () => {
|
||||
|
||||
expect(subRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'test-id' },
|
||||
relations: ['inbounds'],
|
||||
relations: ['inbounds', 'node', 'relayServer'],
|
||||
});
|
||||
expect(subRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'New Name' }),
|
||||
@@ -329,8 +355,8 @@ describe('SubscriptionsService', () => {
|
||||
|
||||
await service.remove('test-id');
|
||||
|
||||
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101);
|
||||
expect(xuiService.deleteInbound).toHaveBeenCalledWith(102);
|
||||
expect(xuiService.deleteInbound).toHaveBeenCalledWith(101, undefined);
|
||||
expect(xuiService.deleteInbound).toHaveBeenCalledWith(102, undefined);
|
||||
expect(subRepo.remove).toHaveBeenCalledWith(subWithInbounds);
|
||||
});
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('TunnelsController', () => {
|
||||
|
||||
await controller.remove('1');
|
||||
|
||||
expect(tunnelsService.remove).toHaveBeenCalledWith(1);
|
||||
expect(tunnelsService.remove).toHaveBeenCalledWith(1, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import { TunnelsService } from 'src/tunnels/tunnels.service';
|
||||
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
|
||||
import { SshService } from 'src/tunnels/ssh.service';
|
||||
import { Setting } from 'src/settings/entities/setting.entity';
|
||||
import { Node } from 'src/nodes/entities/node.entity';
|
||||
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
|
||||
|
||||
describe('TunnelsService', () => {
|
||||
let service: TunnelsService;
|
||||
@@ -34,6 +36,15 @@ describe('TunnelsService', () => {
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockNodeRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSubscriptionRepo = {
|
||||
find: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSshService = {
|
||||
executeCommand: jest.fn(),
|
||||
};
|
||||
@@ -50,6 +61,14 @@ describe('TunnelsService', () => {
|
||||
provide: getRepositoryToken(Setting),
|
||||
useValue: mockSettingRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Node),
|
||||
useValue: mockNodeRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Subscription),
|
||||
useValue: mockSubscriptionRepo,
|
||||
},
|
||||
{
|
||||
provide: SshService,
|
||||
useValue: mockSshService,
|
||||
@@ -64,21 +83,27 @@ describe('TunnelsService', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('должен создать туннель', async () => {
|
||||
const dto = { ip: '192.168.1.1', sshPort: 22, username: 'root' };
|
||||
const mockTunnel = { id: 1, ...dto };
|
||||
const node = { id: 'node-1', isMain: true };
|
||||
const mockTunnel = { id: 1, ...dto, node, nodeId: node.id };
|
||||
|
||||
mockNodeRepo.findOne.mockResolvedValue(node);
|
||||
mockTunnelRepo.create.mockReturnValue(mockTunnel);
|
||||
mockTunnelRepo.save.mockResolvedValue(mockTunnel);
|
||||
|
||||
const result = await service.create(dto);
|
||||
|
||||
expect(result).toEqual(mockTunnel);
|
||||
expect(tunnelRepo.create).toHaveBeenCalledWith(dto);
|
||||
expect(tunnelRepo.create).toHaveBeenCalledWith({
|
||||
...dto,
|
||||
node,
|
||||
nodeId: node.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,12 +119,13 @@ describe('TunnelsService', () => {
|
||||
const result = await service.findAll();
|
||||
|
||||
expect(result).toEqual(mockTunnels);
|
||||
expect(tunnelRepo.find).toHaveBeenCalledTimes(1);
|
||||
expect(tunnelRepo.find).toHaveBeenCalledWith({ relations: ['node'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('должен удалить туннель по ID', async () => {
|
||||
mockSubscriptionRepo.find.mockResolvedValue([]);
|
||||
mockTunnelRepo.delete.mockResolvedValue({ affected: 1 });
|
||||
|
||||
await service.remove(1);
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('XuiService', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
@@ -149,7 +149,16 @@ describe('XuiService', () => {
|
||||
|
||||
describe('deleteInbound', () => {
|
||||
it('должен удалить инбаунд', async () => {
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: { success: true } });
|
||||
mockSettingsRepo.find.mockResolvedValue([
|
||||
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||
{ key: 'xui_login', value: 'admin' },
|
||||
{ key: 'xui_password', value: 'password' },
|
||||
]);
|
||||
mockAxiosInstance.post
|
||||
.mockResolvedValueOnce({
|
||||
headers: { 'set-cookie': ['session=abc123'] },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { success: true } });
|
||||
|
||||
await service.deleteInbound(101);
|
||||
|
||||
@@ -159,16 +168,35 @@ describe('XuiService', () => {
|
||||
});
|
||||
|
||||
it('должен обработать ошибку удаления', async () => {
|
||||
mockAxiosInstance.post.mockRejectedValue(new Error('Not found'));
|
||||
mockSettingsRepo.find.mockResolvedValue([
|
||||
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||
{ key: 'xui_login', value: 'admin' },
|
||||
{ key: 'xui_password', value: 'password' },
|
||||
]);
|
||||
mockAxiosInstance.post
|
||||
.mockResolvedValueOnce({
|
||||
headers: { 'set-cookie': ['session=abc123'] },
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('Not found'));
|
||||
|
||||
await service.deleteInbound(999);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalled();
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
|
||||
'/panel/api/inbounds/del/999',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNewX25519Cert', () => {
|
||||
it('должен получить Reality ключи', async () => {
|
||||
mockSettingsRepo.find.mockResolvedValue([
|
||||
{ key: 'xui_url', value: 'http://localhost:3100' },
|
||||
{ key: 'xui_login', value: 'admin' },
|
||||
{ key: 'xui_password', value: 'password' },
|
||||
]);
|
||||
mockAxiosInstance.post.mockResolvedValueOnce({
|
||||
headers: { 'set-cookie': ['session=abc123'] },
|
||||
});
|
||||
mockAxiosInstance.get.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
|
||||
Reference in New Issue
Block a user