gui version

This commit is contained in:
Den Piligrim
2026-01-28 12:05:10 +03:00
parent f574931c25
commit ba6db6edd5
102 changed files with 19151 additions and 2673 deletions
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
+58
View File
@@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { Setting } from './settings/entities/setting.entity';
import { Domain } from './domains/entities/domain.entity';
import { Subscription } from './subscriptions/entities/subscription.entity';
import { Inbound } from './inbounds/entities/inbound.entity';
import { XuiModule } from './xui/xui.module';
import { InboundsModule } from './inbounds/inbounds.module';
import { RotationModule } from './rotation/rotation.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { DomainsModule } from './domains/domains.module';
import { SettingsModule } from './settings/settings.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { APP_GUARD } from '@nestjs/core';
import { AuthModule } from './auth/auth.module';
import { ClientModule } from './client/client.module';
import { TunnelsModule } from './tunnels/tunnels.module';
import { Tunnel } from './tunnels/entities/tunnel.entity';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [Setting, Domain, Subscription, Inbound, Tunnel],
synchronize: true,
}),
XuiModule,
InboundsModule,
RotationModule,
SubscriptionsModule,
DomainsModule,
SettingsModule,
AuthModule,
ClientModule,
TunnelsModule
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule { }
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Controller, Post, Body, Request, UseGuards, Get } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@Post('login')
async login(@Body() req) {
const user = await this.authService.validateUser(req.login, req.password);
if (!user) {
throw new Error('Invalid credentials');
}
return this.authService.login(user);
}
@Post('change-password')
async changePassword(@Body('password') password: string) {
await this.authService.changePassword(password);
return { success: true };
}
@Post('update-profile')
async updateProfile(@Body() body: { login: string; password?: string }) {
await this.authService.updateAdminProfile(body.login, body.password);
return { success: true };
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Setting } from '../settings/entities/setting.entity';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
@Module({
imports: [
TypeOrmModule.forFeature([Setting]),
PassportModule,
JwtModule.register({
secret: 'SECRET_KEY_CHANGE_ME',
signOptions: { expiresIn: '24h' },
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
+110
View File
@@ -0,0 +1,110 @@
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { Setting } from '../settings/entities/setting.entity';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
private jwtService: JwtService,
) {}
async validateUser(login: string, pass: string): Promise<any> {
this.logger.log(`Попытка входа с логином: ${login}`);
const dbLogin = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
const dbPass = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
if (!dbLogin) {
this.logger.error('Пользователь admin_login не найден в базе данных!');
return null;
}
if (!dbPass) {
this.logger.error('Пароль admin_password не найден в базе данных!');
return null;
}
this.logger.log(`Пользователь найден, проверяем хеш пароля...`);
// Сравниваем пароль
const isMatch = await bcrypt.compare(pass, dbPass.value);
if (isMatch) {
this.logger.log('Пароль верный!');
return { login: dbLogin.value };
} else {
this.logger.warn('Пароль неверный.');
return null;
}
}
async login(user: any) {
const payload = { username: user.login };
return {
access_token: this.jwtService.sign(payload),
};
}
async changePassword(newPass: string) {
const hash = await bcrypt.hash(newPass, 10);
let setting = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
if (!setting) {
setting = this.settingsRepo.create({ key: 'admin_password' });
}
setting.value = hash;
await this.settingsRepo.save(setting);
this.logger.log('Пароль администратора изменен.');
}
// ... imports
// (Оставьте существующие методы без изменений, добавьте/обновите этот)
async updateAdminProfile(login: string, password?: string) {
let loginSetting = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
if (!loginSetting) loginSetting = this.settingsRepo.create({ key: 'admin_login' });
loginSetting.value = login;
await this.settingsRepo.save(loginSetting);
if (password && password.trim().length > 0) {
const hash = await bcrypt.hash(password, 10);
let passSetting = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
if (!passSetting) passSetting = this.settingsRepo.create({ key: 'admin_password' });
passSetting.value = hash;
await this.settingsRepo.save(passSetting);
}
this.logger.log(`Профиль администратора обновлен. Новый логин: ${login}`);
}
// Обновленный метод инициализации
async seedAdmin() {
const login = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
// Если пользователя нет ИЛИ если нужно принудительно сбросить (для отладки)
if (!login) {
this.logger.log('Инициализация администратора (admin / admin)...');
// 1. Сохраняем логин
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: 'admin' });
await this.settingsRepo.save(loginSetting);
// 2. Сохраняем пароль
const hash = await bcrypt.hash('admin', 10);
const passSetting = this.settingsRepo.create({ key: 'admin_password', value: hash });
await this.settingsRepo.save(passSetting);
this.logger.log('Администратор успешно создан.');
} else {
this.logger.log('Администратор уже существует в базе.');
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: 'SECRET_KEY_CHANGE_ME',
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
}
}
+2
View File
@@ -0,0 +1,2 @@
import { SetMetadata } from '@nestjs/common';
export const Public = () => SetMetadata('isPublic', true);
+133
View File
@@ -0,0 +1,133 @@
import { Controller, Get, Param, HttpException, HttpStatus, Res, Req, Inject } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { Response, Request } from 'express';
import * as QRCode from 'qrcode';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Public } from '../auth/public.decorator';
@Controller() // Убираем 'client', так как путь зададим явно
export class ClientController {
constructor(
@InjectRepository(Subscription)
private subRepo: Repository<Subscription>,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) { }
@Public()
@Get('bus/:uuid') // Тот самый путь /bus/UUID
async getSubscription(
@Param('uuid') uuid: string,
@Req() req: Request,
@Res() res: Response
) {
// 1. Ищем подписку
const sub = await this.subRepo.findOne({
where: { uuid },
relations: ['inbounds']
});
if (!sub || !sub.isEnabled) {
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
}
// 2. Генерируем список ссылок (Config)
const links = sub.inbounds
?.map(i => i.link)
.filter(l => l && l.length > 0) || [];
// Формируем Base64 строку (это и есть подписка для клиента)
const plainTextList = links.join('\n');
const base64Config = Buffer.from(plainTextList).toString('base64');
const userAgent = req.headers['user-agent'] || '';
const isBrowser = /Mozilla|Chrome|Safari|Firefox|Edge/.test(userAgent);
if (!isBrowser) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(base64Config);
} else {
const currentUrl = `${req.protocol}://${req.get('host')}/bus/${uuid}`;
const cacheKey = `qr_${uuid}`;
let qrDataUrl = await this.cacheManager.get<string>(cacheKey);
if (!qrDataUrl) {
qrDataUrl = await QRCode.toDataURL(currentUrl, { width: 300, margin: 2 });
await this.cacheManager.set(cacheKey, qrDataUrl, 86400000);
} else {
console.log(`Взяли QR из кэша для ${uuid}`);
}
// HTML шаблон
const html = `
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${sub.name} | 3DP-MANAGER</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f4f6f8; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: white; padding: 2rem; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 90%; }
h2 { margin-top: 0; color: #333; }
.qr-box { background: #fff; padding: 10px; border: 1px solid #eee; border-radius: 8px; display: inline-block; margin: 20px 0; }
.link-box { background: #f5f5f5; padding: 10px; border-radius: 6px; font-family: monospace; word-break: break-all; font-size: 12px; color: #666; margin-bottom: 20px; border: 1px solid #e0e0e0; }
button { background-color: #1976d2; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 16px; cursor: pointer; transition: background 0.2s; width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; }
button:hover { background-color: #1565c0; }
button:active { transform: scale(0.98); }
.note { margin-top: 20px; font-size: 12px; color: #999; }
#subscription-links { display: none; }
</style>
</head>
<body>
<div class="card">
<h2>Ваша подписка</h2>
<p style="color: #666;">Отсканируйте QR-код в приложении Happ, v2RayTun или Streisand</p>
<div class="qr-box">
<img src="${qrDataUrl}" alt="QR Code" />
</div>
<div class="link-box" id="link-text">${currentUrl}</div>
<button onclick="copyLink()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"><path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/></svg>
Копировать ссылку
</button>
<div class="note">Для автоматического обновления конфигов используйте эту ссылку</div>
</div>
<textarea id="subscription-links">${base64Config}</textarea>
<script>
function copyLink() {
const link = document.getElementById('link-text').innerText;
navigator.clipboard.writeText(link).then(() => {
const btn = document.querySelector('button');
const originalText = btn.innerHTML;
btn.innerHTML = 'Скопировано!';
btn.style.backgroundColor = '#2e7d32';
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.backgroundColor = '#1976d2';
}, 2000);
});
}
</script>
</body>
</html>
`;
res.setHeader('Content-Type', 'text/html');
res.send(html);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClientController } from './client.controller';
import { CacheModule } from '@nestjs/cache-manager';
import { Subscription } from '../subscriptions/entities/subscription.entity';
@Module({
imports: [TypeOrmModule.forFeature([Subscription]), CacheModule.register()],
controllers: [ClientController],
})
export class ClientModule {}
+46
View File
@@ -0,0 +1,46 @@
import { Controller, Get, Post, Body, Param, Delete, Query } from '@nestjs/common';
import { DomainsService } from './domains.service';
@Controller('domains')
export class DomainsController {
constructor(private readonly domainsService: DomainsService) { }
@Post()
create(@Body() body: { name: string }) {
return this.domainsService.create(body);
}
// Загрузка списка (массива строк)
@Post('upload')
uploadMany(@Body() body: { domains: string[] }) {
return this.domainsService.createMany(body.domains);
}
@Get()
findAll(
@Query('page') page: number,
@Query('limit') limit: number
) {
// Если параметры не передали, ставим дефолтные: стр 1, лимит 10
const pageNum = page ? +page : 1;
const limitNum = limit ? +limit : 10;
return this.domainsService.findAll(pageNum, limitNum);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.domainsService.findOne(+id);
}
// ВАЖНО: @Delete('all') должен идти ПЕРЕД @Delete(':id')
@Delete('all')
removeAll() {
return this.domainsService.removeAll();
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.domainsService.remove(+id);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DomainsService } from './domains.service';
import { DomainsController } from './domains.controller';
import { Domain } from './entities/domain.entity';
@Module({
imports: [TypeOrmModule.forFeature([Domain])],
controllers: [DomainsController],
providers: [DomainsService],
exports: [DomainsService],
})
export class DomainsModule {}
+80
View File
@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Domain } from './entities/domain.entity';
@Injectable()
export class DomainsService {
constructor(
@InjectRepository(Domain)
private repo: Repository<Domain>,
) { }
// Создать один домен
async create(createDomainDto: { name: string }) {
// Простейшая проверка на дубликат (можно и через try-catch)
const exists = await this.repo.findOne({ where: { name: createDomainDto.name } });
if (exists) return exists;
const domain = this.repo.create(createDomainDto);
return this.repo.save(domain);
}
async findAll(page: number = 1, limit: number = 10) {
const skip = (page - 1) * limit;
const [result, total] = await this.repo.findAndCount({
take: limit, // Сколько взять (10)
skip: skip, // Сколько пропустить
order: { id: 'DESC' }, // Сортируем: новые сверху
});
return {
data: result,
total: total,
};
}
findOne(id: number) {
return this.repo.findOneBy({ id });
}
// Удалить один
remove(id: number) {
return this.repo.delete(id);
}
// === СПЕЦИАЛЬНЫЕ МЕТОДЫ ===
// 1. Удалить вообще всё (для кнопки "Удалить все")
async removeAll() {
await this.repo.clear(); // TRUNCATE table
return { success: true };
}
// 2. Массовая загрузка из файла
async createMany(names: string[]) {
if (!names || names.length === 0) return { count: 0 };
// Убираем пробелы и пустые строки
const cleanNames = names
.map(n => n.trim())
.filter(n => n.length > 0);
// Получаем текущие домены, чтобы не вставлять дубли
const existing = await this.repo.find();
const existingSet = new Set(existing.map(d => d.name));
// Оставляем только новые
const uniqueNewNames = [...new Set(cleanNames)] // убираем дубли внутри самого файла
.filter(name => !existingSet.has(name)); // убираем те, что уже есть в БД
if (uniqueNewNames.length === 0) return { count: 0 };
// Создаем и сохраняем
const entities = uniqueNewNames.map(name => this.repo.create({ name }));
await this.repo.save(entities);
return { count: entities.length };
}
}
@@ -0,0 +1,13 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Domain {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
name: string;
@Column({ default: true })
isEnabled: boolean;
}
@@ -0,0 +1,26 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import { Subscription } from '../../subscriptions/entities/subscription.entity';
@Entity()
export class Inbound {
@PrimaryGeneratedColumn()
id: number;
@Column()
xuiId: number;
@Column()
port: number;
@Column()
protocol: string;
@Column({ nullable: true })
remark: string;
@Column({ type: 'text', nullable: true })
link: string;
@ManyToOne(() => Subscription, (sub) => sub.inbounds, { onDelete: 'CASCADE' })
subscription: Subscription;
}
@@ -0,0 +1,342 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class InboundBuilderService {
private readonly flag = process.env.COUNTRY_FLAG ?? '%F0%9F%92%AF';
buildVlessRealityTcp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-tcp-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: 'xtls-rprx-vision', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'tcp',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
tcpSettings: { acceptProxyProtocol: false, header: { type: 'none' } }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'], metadataOnly: false, routeOnly: false })
};
}
buildVlessRealityXhttp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-xhttp-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: 'xtls-rprx-vision', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'xhttp',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
xhttpSettings: { path: '/', mode: 'auto' }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'], metadataOnly: false, routeOnly: false })
};
}
buildVlessRealityGrpc(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-grpc-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: '', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'grpc',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
},
grpcSettings: { serviceName: 'grpc', multiMode: false }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildVlessWs(params: { port: number; uuid: string; domain: string }) {
const { port, uuid, domain } = params;
return {
enable: true,
port,
protocol: 'vless',
remark: `vless-ws`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: '', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
decryption: 'none',
encryption: 'none',
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'ws',
security: 'none',
externalProxy: [],
wsSettings: { path: '/', headers: { Host: domain } }
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildVmessTcp(params: { port: number; uuid: string }) {
const { port, uuid } = params;
return {
enable: true,
port,
protocol: 'vmess',
remark: 'vmess-tcp',
settings: JSON.stringify({
clients: [{ id: uuid, alterId: 0, email: uuid, limitIp: 0, totalGB: 0, expiryTime: 0, enable: true, tgId: '', subId: '', reset: 0 }],
disableInsecureEncryption: false
}),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none', tcpSettings: { header: { type: 'http', request: { method: 'GET', path: ['/'], headers: { Host: [] } } } } }),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildShadowsocksTcp(params: { port: number; uuid: string }) {
const { port, uuid } = params;
return {
enable: true,
port,
protocol: 'shadowsocks',
remark: 'shadowsocks-tcp',
settings: JSON.stringify({
method: 'aes-256-gcm',
password: uuid,
network: 'tcp,udp',
clients: []
}),
streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
buildTrojanRealityTcp(params: { port: number; uuid: string; domain: string; privateKey: string; publicKey: string }) {
const { port, uuid, domain, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: 'trojan',
remark: `trojan-tcp-reality`,
settings: JSON.stringify({
clients: [{ password: uuid, email: uuid, limitIp: 0, totalGB: 0, expiryTime: 0, enable: true, tgId: '', subId: '', reset: 0 }],
fallbacks: []
}),
streamSettings: JSON.stringify({
network: 'tcp',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
xver: 0,
target: `${domain}:443`,
dest: `${domain}:443`,
serverNames: [domain],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
}
}),
sniffing: JSON.stringify({ enabled: true, destOverride: ['http', 'tls', 'quic', 'fakedns'] })
};
}
generateUuid() {
return uuidv4();
}
buildInboundLink(inbound: any, domain: string, idOrPass: string): string {
let link = "";
switch (inbound.protocol) {
case "vless":
link = this.buildVlessLink(inbound, domain, idOrPass);
break;
case "vmess":
link = this.buildVmessLink(inbound, domain, idOrPass);
break;
case "shadowsocks":
link = this.buildSsLink(inbound, domain, idOrPass);
break;
case "trojan":
link = this.buildTrojanLink(inbound, domain, idOrPass);
break;
}
return link;
}
private buildVlessLink(inbound: any, domain: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings);
const settings = JSON.parse(inbound.settings);
const network = stream.network;
const security = stream.security || "none";
const params = new URLSearchParams();
params.set("type", network);
params.set("encryption", "none");
params.set("security", security);
if (security === "reality") {
const r = stream.realitySettings;
params.set("pbk", r.settings.publicKey);
params.set("fp", r.settings.fingerprint || "random");
params.set("sni", r.serverNames?.[0] || "");
params.set("sid", r.shortIds?.[0] || "");
params.set("spx", '/');
if (network === "tcp") {
const client = settings.clients?.[0];
if (client?.flow) {
params.set("flow", client.flow);
}
}
if (network === "xhttp") {
const x = stream.xhttpSettings || {};
params.set("path", x.path || "/");
params.set("host", x.host || r.serverNames?.[0]);
params.set("mode", x.mode || "auto");
}
if (network === "grpc") {
const g = stream.grpcSettings || {};
params.set("serviceName", g.serviceName || "grpc");
params.set("authority", g.authority || r.serverNames?.[0]);
}
}
if (network === "ws") {
const ws = stream.wsSettings || {};
params.set("path", ws.path || "/");
if (ws.headers?.Host) {
params.set("host", ws.headers.Host);
}
}
return (
`vless://${uuid}@${domain}:${inbound.port}` +
`?${params.toString()}` +
`#${this.flag}%20${encodeURIComponent(inbound.remark)}`
);
}
private buildVmessLink(inbound: any, domain: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings);
const vmessObj = {
add: domain,
aid: '',
alpn: "",
fp: "",
host: "",
id: uuid,
net: stream.network || "tcp",
path: "/",
port: inbound.port,
ps: decodeURIComponent(this.flag) + ' ' + inbound.remark,
scy: "",
sni: "",
tls: stream.security || "none",
type: "none",
v: "2"
};
const base64 = Buffer
.from(JSON.stringify(vmessObj), "utf8")
.toString("base64");
return `vmess://${base64}`;
}
private buildSsLink(inbound: any, domain: string, idOrPass: string) {
const settings = JSON.parse(inbound.settings);
const method = settings.method;
const serverPassword = settings.password;
const finalPass = serverPassword || idOrPass;
const userInfo = `${method}:${finalPass}`;
const base64 = Buffer
.from(userInfo, "utf8")
.toString("base64");
return `ss://${base64}@${domain}:${inbound.port}?type=tcp#${this.flag}%20${inbound.remark}`;
}
private buildTrojanLink(inbound: any, domain: string, password: string) {
const stream = JSON.parse(inbound.streamSettings);
const reality = stream.realitySettings;
const pbk = reality.settings.publicKey;
const sni = reality.serverNames?.[0] || domain;
const sid = reality.shortIds?.[0] || "";
const spx = '%2F';
return (
`trojan://${password}@${domain}:${inbound.port}` +
`?type=tcp` +
`&security=reality` +
`&pbk=${pbk}` +
`&fp=random` +
`&sni=${sni}` +
`&sid=${sid}` +
`&spx=${spx}` +
`#${this.flag}%20${inbound.remark}`
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Inbound } from './entities/inbound.entity';
import { InboundBuilderService } from './inbound-builder.service';
@Module({
imports: [TypeOrmModule.forFeature([Inbound])],
providers: [InboundBuilderService],
exports: [InboundBuilderService],
})
export class InboundsModule {}
+19
View File
@@ -0,0 +1,19 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { AuthService } from './auth/auth.service';
import { RequestMethod } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const authService = app.get(AuthService);
await authService.seedAdmin();
app.enableCors();
app.setGlobalPrefix('api', {
exclude: [{ path: 'bus/:uuid', method: RequestMethod.GET }]
});
await app.listen(3000);
}
bootstrap();
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { RotationService } from './rotation.service';
import { XuiModule } from '../xui/xui.module';
import { InboundsModule } from '../inbounds/inbounds.module';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { Domain } from '../domains/entities/domain.entity';
import { Setting } from '../settings/entities/setting.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Subscription, Inbound, Domain, Setting]),
ScheduleModule.forRoot(),
XuiModule,
InboundsModule,
],
providers: [RotationService],
})
export class RotationModule {}
+169
View File
@@ -0,0 +1,169 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { Domain } from '../domains/entities/domain.entity';
import { Setting } from '../settings/entities/setting.entity';
import { XuiService } from '../xui/xui.service';
import { InboundBuilderService } from '../inbounds/inbound-builder.service';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class RotationService {
private readonly logger = new Logger(RotationService.name);
constructor(
@InjectRepository(Subscription) private subRepo: Repository<Subscription>,
@InjectRepository(Inbound) private inboundRepo: Repository<Inbound>,
@InjectRepository(Domain) private domainRepo: Repository<Domain>,
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
private xuiService: XuiService,
private inboundBuilder: InboundBuilderService,
) {}
@Cron(CronExpression.EVERY_MINUTE)
async handleTicker() {
const intervalSetting = await this.settingRepo.findOne({ where: { key: 'rotation_interval' } });
const intervalMinutes = intervalSetting ? parseInt(intervalSetting.value, 10) : 30;
const lastRunSetting = await this.settingRepo.findOne({ where: { key: 'last_rotation_timestamp' } });
const lastRun = lastRunSetting ? parseInt(lastRunSetting.value, 10) : 0;
const now = Date.now();
const diffMinutes = (now - lastRun) / 1000 / 60;
if (diffMinutes < intervalMinutes) {
return;
}
await this.performRotation();
await this.saveSetting('last_rotation_timestamp', now.toString());
}
private async saveSetting(key: string, value: string) {
let s = await this.settingRepo.findOne({ where: { key } });
if (!s) s = this.settingRepo.create({ key });
s.value = value;
await this.settingRepo.save(s);
}
private async performRotation() {
this.logger.log('Запуск плановой ротации...');
const isLoginSuccess = await this.xuiService.login();
if (!isLoginSuccess) {
this.logger.error('Отмена ротации: Не удалось войти в панель 3x-ui');
return;
}
const subscriptions = await this.subRepo.find({ where: { isEnabled: true }, relations: ['inbounds'] });
if (subscriptions.length === 0) return;
const domains = await this.domainRepo.find({ where: { isEnabled: true } });
if (domains.length === 0) {
this.logger.warn('Список доменов пуст! Ротация невозможна.');
return;
}
for (const sub of subscriptions) {
await this.rotateSubscription(sub, domains);
}
this.logger.log('Ротация завершена.');
}
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);
await this.inboundRepo.delete(inbound.id);
}
}
const keys = await this.xuiService.getNewX25519Cert();
if (!keys) {
this.logger.error("Не удалось получить Reality ключи, пропускаем подписку");
return;
}
const usedPorts = new Set<number>();
const tasks = [
() => this.inboundBuilder.buildVlessRealityTcp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }), // Port 8443 pref
() => this.inboundBuilder.buildVlessRealityXhttp({ port: 0, uuid: uuidv4(), domain: this.pickDomain(domains), ...keys }), // Port 443 pref
() => 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';
for (const [index, task] of tasks.entries()) {
let config = task();
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;
usedPorts.add(port);
const xuiId = await this.xuiService.addInbound(config);
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);
const newInbound = this.inboundRepo.create({
xuiId: xuiId,
port: port,
protocol: config.protocol,
remark: config.remark,
link: fullLink,
subscription: sub
});
await this.inboundRepo.save(newInbound);
}
}
}
private pickDomain(list: Domain[]): string {
return list[Math.floor(Math.random() * list.length)].name;
}
private async getFreePort(preferred: number, currentBatch: Set<number>): Promise<number> {
if (preferred > 0 && !currentBatch.has(preferred)) {
const exists = await this.inboundRepo.findOne({ where: { port: preferred } });
if (!exists) return preferred;
}
while (true) {
const p = Math.floor(Math.random() * (60000 - 10000)) + 10000;
if (currentBatch.has(p)) continue;
const exists = await this.inboundRepo.findOne({ where: { port: p } });
if (!exists) return p;
}
}
}
@@ -0,0 +1,13 @@
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity()
export class Setting {
@PrimaryColumn()
key: string;
@Column()
value: string;
@Column({ nullable: true })
description: string;
}
@@ -0,0 +1,40 @@
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 dns from 'dns/promises';
@Controller('settings')
export class SettingsController {
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
) {}
@Get()
async findAll() {
const settings = await this.settingsRepo.find();
return settings.reduce((acc, curr) => ({ ...acc, [curr.key]: curr.value }), {});
}
@Post()
async update(@Body() settings: Record<string, string>) {
if (settings.xui_url) {
try {
const parsed = new URL(settings.xui_url);
settings['xui_host'] = parsed.hostname;
const { address } = await dns.lookup(parsed.hostname);
settings['xui_ip'] = address;
console.log(`Extracted host: ${parsed.hostname} from ${settings.xui_url}`);
} catch (e) {
console.warn(`Не удалось извлечь хост из URL: ${settings.xui_url}`);
}
}
for (const [key, value] of Object.entries(settings)) {
await this.settingsRepo.save({ key, value });
}
return { success: true };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SettingsController } from './settings.controller';
import { Setting } from './entities/setting.entity';
@Module({
imports: [TypeOrmModule.forFeature([Setting])],
controllers: [SettingsController],
})
export class SettingsModule {}
@@ -0,0 +1,26 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Inbound } from '../../inbounds/entities/inbound.entity';
@Entity()
export class Subscription {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ unique: true })
uuid: string;
@Column({ default: true })
isEnabled: boolean;
@OneToMany(() => Inbound, (inbound) => inbound.subscription)
inbounds: Inbound[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
@@ -0,0 +1,22 @@
import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
@Controller('subscriptions')
export class SubscriptionsController {
constructor(private readonly subscriptionsService: SubscriptionsService) {}
@Get()
findAll() {
return this.subscriptionsService.findAll();
}
@Post()
create(@Body('name') name: string) {
return this.subscriptionsService.create(name);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.subscriptionsService.remove(id);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SubscriptionsService } from './subscriptions.service';
import { SubscriptionsController } from './subscriptions.controller';
import { Subscription } from './entities/subscription.entity';
import { Inbound } from '../inbounds/entities/inbound.entity';
import { XuiModule } from '../xui/xui.module';
@Module({
imports: [TypeOrmModule.forFeature([Subscription, Inbound]), XuiModule],
controllers: [SubscriptionsController],
providers: [SubscriptionsService],
exports: [SubscriptionsService],
})
export class SubscriptionsModule {}
@@ -0,0 +1,43 @@
import { Injectable } 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 { v4 as uuidv4 } from 'uuid';
@Injectable()
export class SubscriptionsService {
constructor(
@InjectRepository(Subscription)
private subRepo: Repository<Subscription>,
@InjectRepository(Inbound)
private inboundRepo: Repository<Inbound>,
private xuiService: XuiService,
) {}
findAll() {
return this.subRepo.find({ relations: ['inbounds'], order: { createdAt: 'DESC' } });
}
async create(name: string) {
const sub = this.subRepo.create({
name,
uuid: uuidv4(),
});
return this.subRepo.save(sub);
}
async remove(id: string) {
const sub = await this.subRepo.findOne({ where: { id }, relations: ['inbounds'] });
if (!sub) return;
if (sub.inbounds) {
for (const inbound of sub.inbounds) {
await this.xuiService.deleteInbound(inbound.xuiId);
}
}
return this.subRepo.remove(sub);
}
}
@@ -0,0 +1,28 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Tunnel {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
ip: string;
@Column({ default: 22 })
sshPort: number;
@Column()
username: string;
@Column({ select: false })
password: string;
@Column({ nullable: true })
domain: string;
@Column({ default: false })
isInstalled: boolean;
}
+49
View File
@@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import { Client } from 'ssh2';
@Injectable()
export class SshService {
private readonly logger = new Logger(SshService.name);
async executeCommand(
config: { host: string; port: number; username: string; password?: string },
command: string
): Promise<string> {
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => {
this.logger.log(`SSH Connection established to ${config.host}`);
conn.exec(command, (err, stream) => {
if (err) {
conn.end();
return reject(err);
}
let output = '';
stream.on('close', (code, signal) => {
this.logger.log(`SSH Command finished with code ${code}`);
conn.end();
if (code === 0) resolve(output);
else reject(new Error(`Exit code ${code}. Output: ${output}`));
}).on('data', (data) => {
output += data.toString();
}).stderr.on('data', (data) => {
output += data.toString();
});
});
}).on('error', (err) => {
this.logger.error(`SSH Error: ${err.message}`);
reject(err);
}).connect({
host: config.host,
port: config.port,
username: config.username,
password: config.password,
readyTimeout: 20000,
});
});
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common';
import { TunnelsService } from './tunnels.service';
import { Tunnel } from './entities/tunnel.entity';
@Controller('tunnels')
export class TunnelsController {
constructor(private readonly tunnelsService: TunnelsService) {}
@Post()
create(@Body() createTunnelDto: Tunnel) {
return this.tunnelsService.create(createTunnelDto);
}
@Get()
findAll() {
return this.tunnelsService.findAll();
}
@Post(':id/install')
install(@Param('id') id: string) {
return this.tunnelsService.installScript(+id);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.tunnelsService.remove(+id);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TunnelsService } from './tunnels.service';
import { TunnelsController } from './tunnels.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tunnel } from './entities/tunnel.entity';
import { Setting } from '../settings/entities/setting.entity';
import { SshService } from './ssh.service';
@Module({
imports: [TypeOrmModule.forFeature([Tunnel, Setting])], // Setting нужен для xui_host
controllers: [TunnelsController],
providers: [TunnelsService, SshService],
})
export class TunnelsModule {}
+79
View File
@@ -0,0 +1,79 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tunnel } from './entities/tunnel.entity';
import { SshService } from './ssh.service';
import { Setting } from '../settings/entities/setting.entity';
@Injectable()
export class TunnelsService {
private readonly logger = new Logger(TunnelsService.name);
constructor(
@InjectRepository(Tunnel) private tunnelRepo: Repository<Tunnel>,
@InjectRepository(Setting) private settingRepo: Repository<Setting>,
private sshService: SshService,
) {}
async create(createTunnelDto: any) {
const tunnel = this.tunnelRepo.create(createTunnelDto);
return this.tunnelRepo.save(tunnel);
}
async findAll() {
return this.tunnelRepo.find();
}
async remove(id: number) {
return this.tunnelRepo.delete(id);
}
// === УСТАНОВКА СКРИПТА ===
async installScript(id: number) {
// 1. Ищем туннель с паролем
const tunnel = await this.tunnelRepo.createQueryBuilder('tunnel')
.addSelect('tunnel.password')
.where('tunnel.id = :id', { id })
.getOne();
if (!tunnel) throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND);
// 2. Ищем IP основного сервера (куда пересылать трафик)
const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
if (!hostSetting || !hostSetting.value) {
throw new HttpException(
'В настройках (Settings) не сохранен Host/IP основного сервера (xui_host). Сохраните настройки подключения к 3x-ui заново.',
HttpStatus.BAD_REQUEST
);
}
const mainServerIp = hostSetting.value;
this.logger.log(`Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`);
// 3. Формируем команду
// export ORIGIN_IP="1.2.3.4" && bash <(curl ...)
const command = `export ORIGIN_IP="${mainServerIp}" && bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)`;
try {
// 4. Выполняем через SSH
const output = await this.sshService.executeCommand({
host: tunnel.ip,
port: tunnel.sshPort,
username: tunnel.username,
password: tunnel.password
}, command);
this.logger.log(`Скрипт выполнен успешно:\n${output}`);
// Помечаем как установленный
tunnel.isInstalled = true;
await this.tunnelRepo.save(tunnel);
return { success: true, output };
} catch (e) {
this.logger.error(`Ошибка SSH: ${e.message}`);
throw new HttpException(`Ошибка установки: ${e.message}`, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { XuiService } from './xui.service';
import { Setting } from '../settings/entities/setting.entity';
@Module({
imports: [TypeOrmModule.forFeature([Setting])],
providers: [XuiService],
exports: [XuiService],
})
export class XuiModule {}
+104
View File
@@ -0,0 +1,104 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import axios, { AxiosInstance } from 'axios';
import * as https from 'https';
import { Setting } from '../settings/entities/setting.entity';
@Injectable()
export class XuiService {
private readonly logger = new Logger(XuiService.name);
private api: AxiosInstance;
private cookie: string | null = null;
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
) {
this.api = axios.create({
timeout: 15000,
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
withCredentials: true,
});
this.api.interceptors.request.use((config) => {
if (this.cookie) {
config.headers['Cookie'] = this.cookie;
}
return config;
});
}
private async getSettings() {
const settings = await this.settingsRepo.find();
const config: Record<string, string> = {};
settings.forEach((s) => (config[s.key] = s.value));
return config;
}
async login() {
try {
const config = await this.getSettings();
if (!config['xui_url'] || !config['xui_login'] || !config['xui_password']) {
this.logger.warn('Настройки 3x-ui не заполнены в БД');
return false;
}
this.api.defaults.baseURL = config['xui_url'];
const res = await this.api.post('/login', {
username: config['xui_login'],
password: config['xui_password'],
});
if (res.headers['set-cookie']) {
this.cookie = res.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
this.logger.log('Успешная авторизация в 3x-ui');
return true;
}
} catch (e) {
this.logger.error(`Ошибка авторизации: ${e.message}`);
}
return false;
}
async addInbound(inboundConfig: any) {
try {
const res = await this.api.post('/panel/api/inbounds/add', inboundConfig);
if (res.data?.success) {
this.logger.log(res.data?.msg);
return res.data.obj.id;
} else {
this.logger.error(res.data?.msg);
}
} catch (e) {
this.logger.error(`Ошибка добавления инбаунда: ${e.message}`);
if (e.response?.status === 401) {
this.logger.log('Сессия истекла, пробуем релогин...');
if (await this.login()) {
return this.addInbound(inboundConfig);
}
}
}
return null;
}
async deleteInbound(id: number) {
try {
await this.api.post(`/panel/api/inbounds/del/${id}`);
this.logger.log(`Инбаунд ${id} удален`);
} catch (e) {
this.logger.error(`Ошибка удаления инбаунда ${id}: ${e.message}`);
}
}
async getNewX25519Cert() {
try {
const res = await this.api.get('/panel/api/server/getNewX25519Cert');
if (res.data?.success) return res.data.obj;
} catch (e) {
this.logger.error('Ошибка получения ключей Reality');
}
return null;
}
}