This commit is contained in:
Den Piligrim
2026-03-21 13:24:47 +03:00
parent 21ae964012
commit 5b2870e04a
17 changed files with 386 additions and 152 deletions
+43 -3
View File
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs';
@Injectable()
export class InboundBuilderService {
@@ -139,8 +140,8 @@ export class InboundBuilderService {
};
}
buildVlessWs(params: { port: number; uuid: string; domain: string }) {
const { port, uuid, domain } = params;
buildVlessWs(params: { port: number; uuid: string; sni: string }) {
const { port, uuid, sni } = params;
return {
enable: true,
port,
@@ -168,7 +169,7 @@ export class InboundBuilderService {
security: "none",
externalProxy: [],
wsSettings: {
host: domain,
host: sni,
path: "/",
acceptProxyProtocol: false,
heartbeatPeriod: 0,
@@ -478,4 +479,43 @@ export class InboundBuilderService {
`#${this.flag}%20${inbound.remark}`
);
}
buildHysteria2Link(serverAddress: string, sni: string, remark: string): string {
let auth = 'YOUR_AUTH';
let obfs = 'salamander';
let obfsPass = 'YOUR_PASS';
let port = 443;
try {
const configPath = '/etc/hysteria/config.yaml';
if (fs.existsSync(configPath)) {
const fileContent = fs.readFileSync(configPath, 'utf8');
const authMatch = fileContent.match(/auth:\s*['"]?([^'"\n]+)['"]?/);
if (authMatch) auth = authMatch[1];
const obfsMatch = fileContent.match(/type:\s*['"]?(salamander)['"]?/);
if (obfsMatch) obfs = obfsMatch[1];
const passMatch = fileContent.match(/password:\s*['"]?([^'"\n]+)['"]?/);
if (passMatch) obfsPass = passMatch[1];
const listenMatch = fileContent.match(/listen:\s*['"]?:(\d+)['"]?/);
if (listenMatch) port = parseInt(listenMatch[1], 10);
} else {
console.warn(`Конфиг Hysteria2 не найден по пути: ${configPath}`);
}
} catch (e) {
console.error('Ошибка чтения конфига Hysteria2', e);
}
const params = new URLSearchParams();
params.set('insecure', '0');
params.set('sni', sni);
params.set('obfs', obfs);
params.set('obfs-password', obfsPass);
return `hy2://${auth}@${serverAddress}:${port}/?${params.toString()}#${this.flag}%20${encodeURIComponent(remark)}`;
}
}
@@ -7,6 +7,7 @@ export const CONNECTION_TYPES = [
'shadowsocks-tcp',
'trojan-tcp-reality',
'hysteria2-udp',
'custom',
] as const;
export type ConnectionType = typeof CONNECTION_TYPES[number];
+90 -36
View File
@@ -102,12 +102,15 @@ export class RotationService implements OnModuleInit {
return { success: true, message: 'Ротация успешно выполнена' };
}
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
this.logger.log(`Ротация для подписки: ${sub.name} (${sub.uuid})`);
// Удаляем старые инбаунды
if (sub.inbounds && sub.inbounds.length > 0) {
for (const inbound of sub.inbounds) {
await this.xuiService.deleteInbound(inbound.xuiId);
if (inbound.xuiId && inbound.xuiId > 0) {
await this.xuiService.deleteInbound(inbound.xuiId);
}
await this.inboundRepo.delete(inbound.id);
}
}
@@ -119,55 +122,105 @@ export class RotationService implements OnModuleInit {
}
const usedPorts = new Set<number>();
const tasks = [
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessRealityXhttp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessRealityGrpc({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessWs({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains) }),
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
() => this.inboundBuilder.buildVmessTcp({ port: 0, uuid: uuidv4() }),
() => this.inboundBuilder.buildShadowsocksTcp({ port: 0, uuid: uuidv4() }),
() => this.inboundBuilder.buildTrojanRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }),
];
const host = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
const serverAddress = host?.value || 'localhost';
const flag = await this.settingRepo.findOne({ where: { key: 'xui_geo_flag' } });
const flagEmoji = flag?.value ?? '%F0%9F%92%AF';
for (const [index, task] of tasks.entries()) {
let config = task();
// Получаем конфиг или пустой массив
const inboundsConfig = sub.inboundsConfig || [];
for (const config of inboundsConfig) {
const type = config.type;
const uuid = uuidv4();
// Определяем SNI
let sni = 'unknown';
if (type !== 'custom' && type !== 'vmess-tcp' && type !== 'shadowsocks-tcp') {
sni = (!config.sni || config.sni === 'random') ? this.pickDomain(domains) : config.sni;
}
// === 1. Обработка Custom ===
if (type === 'custom') {
const newInbound = this.inboundRepo.create({
xuiId: 0, // Не привязано к 3x-ui
port: 0,
protocol: 'custom',
remark: 'custom-link',
link: config.link || '',
subscription: sub
});
await this.inboundRepo.save(newInbound);
continue;
}
// === 2. Обработка Hysteria2 ===
if (type === 'hysteria2-udp') {
const link = this.inboundBuilder.buildHysteria2Link(serverAddress, sni, 'Hysteria2');
const newInbound = this.inboundRepo.create({
xuiId: 0,
port: 0, // Обычно Hysteria висит на 443, фактический порт вытаскивается в билдере
protocol: 'hysteria2',
remark: 'hysteria2-udp',
link: link,
subscription: sub
});
await this.inboundRepo.save(newInbound);
continue;
}
// === 3. Обработка стандартных инбаундов Xray (3x-ui) ===
// Определяем порт
let port = 0;
if (index === 0) port = await this.getFreePort(8443, usedPorts);
else if (index === 1) port = await this.getFreePort(443, usedPorts);
else port = await this.getFreePort(0, usedPorts);
config.port = port;
if (config.port === 'random' || !config.port) {
port = await this.getFreePort(0, usedPorts);
} else {
// Если передан конкретный порт строкой или числом
port = typeof config.port === 'string' ? parseInt(config.port, 10) : config.port;
}
usedPorts.add(port);
const xuiId = await this.xuiService.addInbound(config);
let xuiConfig: any;
switch (type) {
case 'vless-tcp-reality':
xuiConfig = this.inboundBuilder.buildVlessRealityTcp({ port, uuid, sni, ...keys });
break;
case 'vless-xhttp-reality':
xuiConfig = this.inboundBuilder.buildVlessRealityXhttp({ port, uuid, sni, ...keys });
break;
case 'vless-grpc-reality':
xuiConfig = this.inboundBuilder.buildVlessRealityGrpc({ port, uuid, sni, ...keys });
break;
case 'vless-ws':
xuiConfig = this.inboundBuilder.buildVlessWs({ port, uuid, sni });
break;
case 'vmess-tcp':
xuiConfig = this.inboundBuilder.buildVmessTcp({ port, uuid });
break;
case 'shadowsocks-tcp':
xuiConfig = this.inboundBuilder.buildShadowsocksTcp({ port, uuid });
break;
case 'trojan-tcp-reality':
xuiConfig = this.inboundBuilder.buildTrojanRealityTcp({ port, uuid, sni, ...keys });
break;
default:
this.logger.warn(`Неизвестный тип инбаунда: ${type}`);
continue;
}
const xuiId = await this.xuiService.addInbound(xuiConfig);
if (xuiId) {
const remarkParts = config.remark.split('-');
let domainForLink = 'unknown';
try {
const ss = JSON.parse(config.streamSettings || '{}');
if (ss.realitySettings?.serverNames?.[0]) domainForLink = ss.realitySettings.serverNames[0];
else if (ss.wsSettings?.headers?.Host) domainForLink = ss.wsSettings.headers.Host;
else if (ss.tcpSettings?.header?.request?.headers?.Host?.[0]) domainForLink = ss.tcpSettings.header.request.headers.Host[0];
} catch (e) { }
const idOrPass = config.settings ? JSON.parse(config.settings).clients?.[0]?.id || JSON.parse(config.settings).clients?.[0]?.password : "";
const fullLink = this.inboundBuilder.buildInboundLink(config, serverAddress, idOrPass, flagEmoji);
const idOrPass = xuiConfig.settings ? JSON.parse(xuiConfig.settings).clients?.[0]?.id || JSON.parse(xuiConfig.settings).clients?.[0]?.password : "";
const fullLink = this.inboundBuilder.buildInboundLink(xuiConfig, serverAddress, idOrPass, flagEmoji);
const newInbound = this.inboundRepo.create({
xuiId: xuiId,
port: port,
protocol: config.protocol,
remark: config.remark,
protocol: xuiConfig.protocol,
remark: xuiConfig.remark,
link: fullLink,
subscription: sub
});
@@ -175,6 +228,7 @@ export class RotationService implements OnModuleInit {
}
}
}
private pickDomain(list: Domain[]): string {
return list[Math.floor(Math.random() * list.length)].name;
}
+9 -2
View File
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Body } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Setting } from './entities/setting.entity';
import * as net from 'net';
import * as dns from 'dns/promises';
import { COUNTRIES } from './countries';
import { XuiService } from 'src/xui/xui.service';
@@ -32,8 +33,14 @@ export class SettingsController {
try {
const parsed = new URL(settings.xui_url);
settings['xui_host'] = parsed.hostname;
const { address } = await dns.lookup(parsed.hostname);
let address = '';
if (net.isIP(parsed.hostname) === 0) {
const result = await dns.lookup(parsed.hostname);
address = result.address;
} else {
address = parsed.hostname;
}
settings['xui_ip'] = address;
console.log(`Extracted host: ${parsed.hostname} from ${settings.xui_url}`);
@@ -11,6 +11,10 @@ export class InboundConfigDto {
@IsString()
@IsOptional()
sni?: string | 'random';
@IsString()
@IsOptional()
link?: string;
}
export class CreateSubscriptionDto {
@@ -15,6 +15,9 @@ export class Subscription {
@Column({ default: true })
isEnabled: boolean;
@Column({ type: 'simple-json', nullable: true })
inboundsConfig: any[];
@OneToMany(() => Inbound, (inbound) => inbound.subscription)
inbounds: Inbound[];
@@ -1,5 +1,6 @@
import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common';
import { Controller, Get, Post, Delete, Body, Param, Put } from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
@Controller('subscriptions')
export class SubscriptionsController {
@@ -11,8 +12,13 @@ export class SubscriptionsController {
}
@Post()
create(@Body('name') name: string) {
return this.subscriptionsService.create(name);
create(@Body() createSubscriptionDto: CreateSubscriptionDto) {
return this.subscriptionsService.create(createSubscriptionDto);
}
@Put(':id')
update(@Param('id') id: string, @Body() updateSubscriptionDto: CreateSubscriptionDto) {
return this.subscriptionsService.update(id, updateSubscriptionDto);
}
@Delete(':id')
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Subscription } from './entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { XuiService } from '../xui/xui.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
@@ -18,11 +18,32 @@ export class SubscriptionsService {
return this.subRepo.find({ relations: ['inbounds'], order: { createdAt: 'DESC' } });
}
async create(name: string) {
async create(dto: CreateSubscriptionDto) {
const sub = this.subRepo.create({
name,
name: dto.name,
uuid: uuidv4(),
inboundsConfig: dto.inboundsConfig || [],
});
return this.subRepo.save(sub);
}
async update(id: string, dto: CreateSubscriptionDto) {
const sub = await this.subRepo.findOne({
where: { id },
relations: ['inbounds']
});
if (!sub) {
throw new NotFoundException(`Subscription with ID ${id} not found`);
}
sub.name = dto.name;
if (dto.inboundsConfig) {
sub.inboundsConfig = dto.inboundsConfig;
}
return this.subRepo.save(sub);
}
@@ -30,7 +51,7 @@ export class SubscriptionsService {
const sub = await this.subRepo.findOne({ where: { id }, relations: ['inbounds'] });
if (!sub) return;
if (sub.inbounds) {
if (sub.inbounds && sub.inbounds.length > 0) {
for (const inbound of sub.inbounds) {
await this.xuiService.deleteInbound(inbound.xuiId);
}
+5 -2
View File
@@ -17,8 +17,11 @@ export class Tunnel {
@Column()
username: string;
@Column({ select: false })
password: string;
@Column({ select: false, nullable: true })
password?: string;
@Column({ type: 'text', select: false, nullable: true })
privateKey?: string;
@Column({ nullable: true })
domain: string;
+2 -1
View File
@@ -6,7 +6,7 @@ export class SshService {
private readonly logger = new Logger(SshService.name);
async executeCommand(
config: { host: string; port: number; username: string; password?: string },
config: { host: string; port: number; username: string; password?: string, privateKey?: string },
command: string
): Promise<string> {
return new Promise((resolve, reject) => {
@@ -42,6 +42,7 @@ export class SshService {
port: config.port,
username: config.username,
password: config.password,
privateKey: config.privateKey,
readyTimeout: 20000,
});
});
+3 -1
View File
@@ -31,6 +31,7 @@ export class TunnelsService {
async installScript(id: number) {
const tunnel = await this.tunnelRepo.createQueryBuilder('tunnel')
.addSelect('tunnel.password')
.addSelect('tunnel.privateKey')
.where('tunnel.id = :id', { id })
.getOne();
@@ -55,7 +56,8 @@ export class TunnelsService {
host: tunnel.ip,
port: tunnel.sshPort,
username: tunnel.username,
password: tunnel.password
password: tunnel.password,
privateKey: tunnel.privateKey
}, command);
this.logger.log(`Скрипт выполнен успешно:\n${output}`);