This commit is contained in:
Den Piligrim
2026-05-21 22:58:29 +03:00
parent 962544820f
commit 26f3bb2934
48 changed files with 2877 additions and 1170 deletions
+142
View File
@@ -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;
});
+105 -13
View File
@@ -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"`);
}
}
+13
View File
@@ -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;
+9
View File
@@ -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;
+5
View File
@@ -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);
+7 -1
View File
@@ -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],
+216 -4
View File
@@ -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(/\/+$/, '');
}
+82 -29
View File
@@ -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)
+2 -1
View File
@@ -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],
})
+87 -12
View File
@@ -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,
);
}
}
+40 -7
View File
@@ -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',
+102
View File
@@ -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);
});
});
+77 -4
View File
@@ -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);
});
});
});
+30 -4
View File
@@ -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);
+32 -4
View File
@@ -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,