gui version
This commit is contained in:
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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('Администратор уже существует в базе.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user