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
+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,
});