refactor: massive codebase stabilization, strict TS, UI/UX overhaul, and central logging

This commit is contained in:
iqubik
2026-03-28 18:33:13 +03:00
parent 388417cec9
commit 8f8a3bf724
69 changed files with 4272 additions and 1693 deletions
+4 -2
View File
@@ -20,6 +20,7 @@ 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';
import { SessionModule } from './session/session.module';
@Module({
imports: [
@@ -36,6 +37,7 @@ import { Tunnel } from './tunnels/entities/tunnel.entity';
entities: [Setting, Domain, Subscription, Inbound, Tunnel],
synchronize: true,
}),
SessionModule,
XuiModule,
InboundsModule,
RotationModule,
@@ -44,7 +46,7 @@ import { Tunnel } from './tunnels/entities/tunnel.entity';
SettingsModule,
AuthModule,
ClientModule,
TunnelsModule
TunnelsModule,
],
controllers: [AppController],
providers: [
@@ -55,4 +57,4 @@ import { Tunnel } from './tunnels/entities/tunnel.entity';
},
],
})
export class AppModule { }
export class AppModule {}
+19 -5
View File
@@ -1,19 +1,33 @@
import { Controller, Post, Body } from '@nestjs/common';
import {
Controller,
Post,
Body,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { Public } from './public.decorator';
interface LoginDto {
login: string;
password: string;
}
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@Post('login')
async login(@Body() req) {
async login(@Body() req: LoginDto) {
const user = await this.authService.validateUser(req.login, req.password);
if (!user) {
throw new Error('Invalid credentials');
throw new HttpException(
'Неверный логин или пароль',
HttpStatus.UNAUTHORIZED,
);
}
return this.authService.login(user);
return this.authService.login(user as { login: string });
}
@Post('change-password')
@@ -27,4 +41,4 @@ export class AuthController {
await this.authService.updateAdminProfile(body.login, body.password);
return { success: true };
}
}
}
+9 -4
View File
@@ -6,18 +6,23 @@ import { Setting } from '../settings/entities/setting.entity';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
TypeOrmModule.forFeature([Setting]),
PassportModule,
JwtModule.register({
secret: 'SECRET_KEY_CHANGE_ME',
signOptions: { expiresIn: '24h' },
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: () => ({
secret: process.env.JWT_SECRET || 'SECRET_KEY_CHANGE_ME',
signOptions: { expiresIn: '24h' },
}),
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
export class AuthModule {}
+54 -30
View File
@@ -17,11 +17,18 @@ export class AuthService {
private configService: ConfigService,
) {}
async validateUser(login: string, pass: string): Promise<any> {
this.logger.log(`Попытка входа с логином: ${login}`);
async validateUser(
login: string,
pass: string,
): Promise<{ login: string } | null> {
this.logger.debug(`Попытка входа с логином: ${login}`);
const dbLogin = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
const dbPass = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
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 не найден в базе данных!');
@@ -33,12 +40,12 @@ export class AuthService {
return null;
}
this.logger.log(`Пользователь найден, проверяем хеш пароля...`);
this.logger.debug(`Пользователь найден, проверяем хеш пароля...`);
const isMatch = await bcrypt.compare(pass, dbPass.value);
if (isMatch) {
this.logger.log('Пароль верный!');
this.logger.debug('Пароль верный!');
return { login: dbLogin.value };
} else {
this.logger.warn('Пароль неверный.');
@@ -46,7 +53,7 @@ export class AuthService {
}
}
async login(user: any) {
login(user: { login: string }) {
const payload = { username: user.login };
return {
access_token: this.jwtService.sign(payload),
@@ -55,52 +62,69 @@ export class AuthService {
async changePassword(newPass: string) {
const hash = await bcrypt.hash(newPass, 10);
let setting = await this.settingsRepo.findOne({ where: { key: 'admin_password' } });
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('Пароль администратора изменен.');
this.logger.debug('Пароль администратора изменен.');
}
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' });
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' });
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}`);
this.logger.debug(`Профиль администратора обновлен. Новый логин: ${login}`);
}
async seedAdmin() {
const login = await this.settingsRepo.findOne({ where: { key: 'admin_login' } });
const login = await this.settingsRepo.findOne({
where: { key: 'admin_login' },
});
if (!login) {
this.logger.log('Инициализация администратора...');
this.logger.debug('Инициализация администратора...');
const envLogin = this.configService.get<string>('ADMIN_LOGIN') || 'admin';
const envPass = this.configService.get<string>('ADMIN_PASSWORD') || 'admin';
const loginSetting = this.settingsRepo.create({ key: 'admin_login', value: envLogin });
const envPass =
this.configService.get<string>('ADMIN_PASSWORD') || 'admin';
const loginSetting = this.settingsRepo.create({
key: 'admin_login',
value: envLogin,
});
await this.settingsRepo.save(loginSetting);
const hash = await bcrypt.hash(envPass, 10);
const passSetting = this.settingsRepo.create({ key: 'admin_password', value: hash });
const passSetting = this.settingsRepo.create({
key: 'admin_password',
value: hash,
});
await this.settingsRepo.save(passSetting);
this.logger.log('Администратор успешно создан.');
this.logger.debug('Администратор успешно создан.');
} else {
this.logger.log('Администратор уже существует в базе.');
this.logger.debug('Администратор уже существует в базе.');
}
}
}
}
+56 -3
View File
@@ -1,21 +1,74 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import {
Injectable,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
private readonly logger = new Logger(JwtAuthGuard.name);
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<Request>();
this.logger.debug(
`canActivate called for: ${request.url} ${request.method}`,
);
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
this.logger.debug(`isPublic: ${isPublic}`);
if (isPublic) {
this.logger.debug(`Skipping public route`);
return true;
}
return super.canActivate(context);
// Support token from query parameter (for SSE connections)
const tokenFromQuery = request.query.token as string | undefined;
if (tokenFromQuery && !request.headers.authorization) {
this.logger.debug(
`Token found in query parameter, adding to Authorization header`,
);
request.headers.authorization = `Bearer ${tokenFromQuery}`;
}
this.logger.debug(`Calling super.canActivate()`);
const result = super.canActivate(context);
this.logger.debug(
`canActivate result: ${typeof result === 'boolean' ? result : 'PENDING'}`,
);
return result;
}
}
handleRequest<TUser = unknown>(
err: unknown,
user: TUser,
_info: unknown,
_context?: unknown,
_status?: unknown,
): TUser {
if (err || !user) {
const errMessage =
err instanceof Error
? err.message
: typeof err === 'string'
? err
: err
? JSON.stringify(err)
: 'null';
this.logger.warn(`handleRequest: ${errMessage || 'Unauthorized'}`);
throw err || new UnauthorizedException();
}
return user;
}
}
+21 -4
View File
@@ -1,18 +1,35 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IncomingHttpHeaders } from 'http';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
constructor(private configService: ConfigService) {
const secret =
configService.get<string>('JWT_SECRET') || 'SECRET_KEY_CHANGE_ME';
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
jwtFromRequest: (req: { headers?: IncomingHttpHeaders }) => {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
return token;
},
ignoreExpiration: false,
secretOrKey: 'SECRET_KEY_CHANGE_ME',
});
const maskedSecret =
secret.length > 8
? `${secret.substring(0, 4)}${'*'.repeat(secret.length - 8)}${secret.substring(secret.length - 4)}`
: '****';
console.log(`[JwtStrategy] Initialized with secret: ${maskedSecret}`);
}
async validate(payload: any) {
validate(payload: { sub: string; username: string }) {
const maskedUsername =
payload.username.length > 6
? `${payload.username.substring(0, 3)}***${payload.username.substring(payload.username.length - 2)}`
: '***';
console.log(`[JwtStrategy] Validating token for user: ${maskedUsername}`);
return { userId: payload.sub, username: payload.username };
}
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
import { SetMetadata } from '@nestjs/common';
export const Public = () => SetMetadata('isPublic', true);
export const Public = () => SetMetadata('isPublic', true);
+62 -148
View File
@@ -1,4 +1,15 @@
import { Controller, Get, Param, HttpException, HttpStatus, Res, Req, Inject, Query } from '@nestjs/common';
import {
Controller,
Get,
Param,
HttpException,
HttpStatus,
Res,
Req,
Inject,
Query,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { Response, Request } from 'express';
@@ -8,36 +19,38 @@ import type { Cache } from 'cache-manager';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Public } from '../auth/public.decorator';
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
import { generateSubscriptionHtmlWithQr } from './templates/subscription.template';
@Controller()
export class ClientController {
private readonly logger = new Logger(ClientController.name);
constructor(
@InjectRepository(Subscription)
private subRepo: Repository<Subscription>,
@InjectRepository(Tunnel)
private tunnelRepo: Repository<Tunnel>,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) { }
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
@Public()
@Get('bus/:uuid')
async getSubscription(
@Param('uuid') uuid: string,
@Req() req: Request,
@Res() res: Response
@Res() res: Response,
) {
const sub = await this.subRepo.findOne({
where: { uuid },
relations: ['inbounds']
relations: ['inbounds'],
});
if (!sub || !sub.isEnabled) {
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
}
const links = sub.inbounds
?.map(i => i.link)
.filter(l => l && l.length > 0) || [];
const links =
sub.inbounds?.map((i) => i.link).filter((l) => l && l.length > 0) || [];
const plainTextList = links.join('\n');
const base64Config = Buffer.from(plainTextList).toString('base64');
@@ -49,7 +62,6 @@ export class ClientController {
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}`;
@@ -57,73 +69,22 @@ export class ClientController {
let qrDataUrl = await this.cacheManager.get<string>(cacheKey);
if (!qrDataUrl) {
qrDataUrl = await QRCode.toDataURL(currentUrl, { width: 300, margin: 2 });
qrDataUrl = await QRCode.toDataURL(currentUrl, {
width: 300,
margin: 2,
});
await this.cacheManager.set(cacheKey, qrDataUrl, 86400000);
} else {
console.log(`Взяли QR из кэша для ${uuid}`);
this.logger.debug(`QR loaded from cache for ${uuid}`);
}
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>
`;
const html = generateSubscriptionHtmlWithQr(
currentUrl,
qrDataUrl,
base64Config,
sub.name,
);
res.setHeader('Content-Type', 'text/html');
res.send(html);
@@ -137,7 +98,7 @@ export class ClientController {
@Param('tunnelId') tunnelId: string,
@Query('format') format: string,
@Req() req: Request,
@Res() res: Response
@Res() res: Response,
) {
const tunnel = await this.tunnelRepo.findOne({ where: { id: +tunnelId } });
if (!tunnel) {
@@ -148,21 +109,22 @@ export class ClientController {
const sub = await this.subRepo.findOne({
where: { uuid },
relations: ['inbounds']
relations: ['inbounds'],
});
if (!sub || !sub.isEnabled) {
throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND);
}
const links = sub.inbounds
?.filter(i => i.link && i.link.length > 0)
.map(i => {
if (i.protocol === 'custom') {
return i.link;
}
return this.patchLink(i.link, relayHost);
}) || [];
const links =
sub.inbounds
?.filter((i) => i.link && i.link.length > 0)
.map((i) => {
if (i.protocol === 'custom') {
return i.link;
}
return this.patchLink(i.link, relayHost);
}) || [];
const plainTextList = links.join('\n');
const base64Config = Buffer.from(plainTextList).toString('base64');
@@ -174,7 +136,6 @@ export class ClientController {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(base64Config);
} else {
const currentUrl = `${req.protocol}://${req.get('host')}/bus/${uuid}/${tunnelId}`;
const cacheKey = `qr_${uuid}_${relayHost || 'direct'}`;
@@ -182,73 +143,22 @@ export class ClientController {
let qrDataUrl = await this.cacheManager.get<string>(cacheKey);
if (!qrDataUrl) {
qrDataUrl = await QRCode.toDataURL(currentUrl, { width: 300, margin: 2 });
qrDataUrl = await QRCode.toDataURL(currentUrl, {
width: 300,
margin: 2,
});
await this.cacheManager.set(cacheKey, qrDataUrl, 86400000);
} else {
console.log(`Взяли QR из кэша для ${uuid}`);
this.logger.debug(`QR loaded from cache for ${uuid}`);
}
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>
`;
const html = generateSubscriptionHtmlWithQr(
currentUrl,
qrDataUrl,
base64Config,
sub.name,
);
res.setHeader('Content-Type', 'text/html');
res.send(html);
@@ -260,17 +170,21 @@ export class ClientController {
try {
const base64Part = link.substring(8);
const jsonStr = Buffer.from(base64Part, 'base64').toString('utf-8');
const config = JSON.parse(jsonStr);
const config = JSON.parse(jsonStr) as { add: string };
config.add = newHost;
const newJsonStr = JSON.stringify(config);
const newBase64 = Buffer.from(newJsonStr).toString('base64');
return `vmess://${newBase64}`;
} catch (e) {
} catch {
return link;
}
} else if (link.startsWith('vless://') || link.startsWith('trojan://') || link.startsWith('hy2://')) {
} else if (
link.startsWith('vless://') ||
link.startsWith('trojan://') ||
link.startsWith('hy2://')
) {
return link.replace(/@.*?:/, `@${newHost}:`);
} else if (link.startsWith('ss://')) {
if (link.includes('@')) {
@@ -281,4 +195,4 @@ export class ClientController {
return link;
}
}
}
@@ -0,0 +1,46 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
} from '@nestjs/common';
import { Response, Request } from 'express';
import { generateErrorHtml } from '../client/templates/subscription.template';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
const message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as { message?: string | string[] })?.message;
const errorMessage = Array.isArray(message) ? message[0] : message;
// Проверяем, что это браузер (не API запрос)
const userAgent = request.headers['user-agent'] || '';
const isBrowser = /Mozilla|Chrome|Safari|Firefox|Edge/.test(userAgent);
// Для endpoint подписки /bus/* возвращаем HTML
if (isBrowser && request.url.includes('/bus/')) {
const html = generateErrorHtml('Подписка не найдена', errorMessage);
response.setHeader('Content-Type', 'text/html; charset=utf-8');
response.status(status).send(html);
} else {
// Для API запросов возвращаем JSON
response.status(status).json({
statusCode: status,
message: errorMessage,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
}
+5 -2
View File
@@ -6,7 +6,10 @@ import { Subscription } from '../subscriptions/entities/subscription.entity';
import { Tunnel } from 'src/tunnels/entities/tunnel.entity';
@Module({
imports: [TypeOrmModule.forFeature([Subscription, Tunnel]), CacheModule.register()],
imports: [
TypeOrmModule.forFeature([Subscription, Tunnel]),
CacheModule.register(),
],
controllers: [ClientController],
})
export class ClientModule {}
export class ClientModule {}
@@ -0,0 +1,372 @@
/**
* Генерирует HTML-страницу для отображения подписки с QR-кодом
* @param currentUrl URL текущей подписки
* @param qrDataUrl Data URL QR-кода
* @param base64Config Base64-кодированная конфигурация подписки
* @param subscriptionName Название подписки
* @returns HTML-строка
*/
export function generateSubscriptionHtmlWithQr(
currentUrl: string,
qrDataUrl: string,
base64Config: string,
subscriptionName: string = 'Ваша подписка',
): string {
return `
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subscriptionName} | 3DP-MANAGER</title>
<style>
:root {
--bg-default: #f3f4f6;
--bg-paper: #ffffff;
--text-primary: #111827;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--card-shadow: 0 4px 20px rgba(0,0,0,0.1);
--qr-box-bg: #fff;
--qr-box-border: #eee;
--link-box-bg: #f5f5f5;
--link-box-border: #e0e0e0;
--button-bg: #1976d2;
--button-hover: #1565c0;
--button-success: #2e7d32;
--error-color: #ef4444;
}
[data-theme="dark"] {
--bg-default: #0B0F19;
--bg-paper: #111827;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--border-color: #374151;
--card-shadow: 0 4px 20px rgba(0,0,0,0.4);
--qr-box-bg: #1f2937;
--qr-box-border: #374151;
--link-box-bg: #1f2937;
--link-box-border: #4b5563;
--button-bg: #1976d2;
--button-hover: #2563eb;
--button-success: #2e7d32;
--error-color: #f87171;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-default);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background: var(--bg-paper);
padding: 2rem;
border-radius: 16px;
box-shadow: var(--card-shadow);
text-align: center;
max-width: 400px;
width: 90%;
border: 1px solid var(--border-color);
transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
h2 { margin-top: 0; color: var(--text-primary); }
.qr-box {
background: var(--qr-box-bg);
padding: 10px;
border: 1px solid var(--qr-box-border);
border-radius: 8px;
display: inline-block;
margin: 20px 0;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.link-box {
background: var(--link-box-bg);
padding: 10px;
border-radius: 6px;
font-family: monospace;
word-break: break-all;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 20px;
border: 1px solid var(--link-box-border);
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
button {
background-color: var(--button-bg);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
button:hover { background-color: var(--button-hover); }
button:active { transform: scale(0.98); }
.note {
margin-top: 20px;
font-size: 12px;
color: var(--text-secondary);
transition: color 0.3s ease;
}
.error-icon {
width: 64px;
height: 64px;
margin: 0 auto 20px;
color: var(--error-color);
}
#subscription-links { display: none; }
</style>
</head>
<body>
<div class="card">
<svg class="error-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v8m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2>${subscriptionName}</h2>
<p style="color: var(--text-secondary);">Отсканируйте 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>
// Синхронизация темы с основным приложением через localStorage
(function() {
function applyTheme() {
const themeMode = localStorage.getItem('themeMode');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// 3DP-MANAGER использует themeMode: light, dark, system
if (themeMode === 'dark' || (themeMode === 'system' && systemDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
}
applyTheme();
// Слушаем изменения темы в localStorage
window.addEventListener('storage', (e) => {
if (e.key === 'themeMode') {
applyTheme();
}
});
// Слушаем изменения системной темы
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const themeMode = localStorage.getItem('themeMode');
if (themeMode === 'system') {
applyTheme();
}
});
})();
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 = 'var(--button-bg)';
}, 2000);
});
}
</script>
</body>
</html>
`;
}
/**
* Генерирует HTML-страницу с ошибкой
* @param title Заголовок ошибки
* @param message Сообщение об ошибке
* @returns HTML-строка
*/
export function generateErrorHtml(
title: string = 'Ошибка',
message: string = 'Произошла ошибка',
): string {
return `
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} | 3DP-MANAGER</title>
<style>
:root {
--bg-default: #f3f4f6;
--bg-paper: #ffffff;
--text-primary: #111827;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--card-shadow: 0 4px 20px rgba(0,0,0,0.1);
--error-color: #ef4444;
--error-bg: #fee2e2;
}
[data-theme="dark"] {
--bg-default: #0B0F19;
--bg-paper: #111827;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--border-color: #374151;
--card-shadow: 0 4px 20px rgba(0,0,0,0.4);
--error-color: #f87171;
--error-bg: #7f1d1d;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-default);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background: var(--bg-paper);
padding: 2rem;
border-radius: 16px;
box-shadow: var(--card-shadow);
text-align: center;
max-width: 400px;
width: 90%;
border: 1px solid var(--border-color);
transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
h2 {
margin-top: 0;
color: var(--text-primary);
}
.error-icon {
width: 64px;
height: 64px;
margin: 0 auto 20px;
color: var(--error-color);
}
.error-message {
background: var(--error-bg);
color: var(--error-color);
padding: 1rem;
border-radius: 8px;
margin: 20px 0;
font-size: 14px;
}
.home-link {
display: inline-block;
margin-top: 20px;
padding: 12px 24px;
background-color: var(--error-color);
color: white;
text-decoration: none;
border-radius: 8px;
font-size: 16px;
transition: opacity 0.2s;
}
.home-link:hover {
opacity: 0.9;
}
.note {
margin-top: 20px;
font-size: 12px;
color: var(--text-secondary);
transition: color 0.3s ease;
}
</style>
</head>
<body>
<div class="card">
<svg class="error-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v8m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2>${title}</h2>
<div class="error-message">${message}</div>
<p class="note">Подписка не найдена или отключена</p>
<a href="/" class="home-link">На главную</a>
</div>
<script>
// Синхронизация темы с основным приложением через localStorage
(function() {
function applyTheme() {
const themeMode = localStorage.getItem('themeMode');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (themeMode === 'dark' || (themeMode === 'system' && systemDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
}
applyTheme();
window.addEventListener('storage', (e) => {
if (e.key === 'themeMode') {
applyTheme();
}
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const themeMode = localStorage.getItem('themeMode');
if (themeMode === 'system') {
applyTheme();
}
});
})();
</script>
</body>
</html>
`;
}
+57 -15
View File
@@ -1,4 +1,12 @@
import { BadRequestException, HttpException, HttpStatus, Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
import {
BadRequestException,
HttpException,
HttpStatus,
Injectable,
InternalServerErrorException,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { spawn, spawnSync } from 'child_process';
import { isIP } from 'net';
@@ -40,15 +48,22 @@ type ScanResult = {
@Injectable()
export class DomainScannerService {
private readonly logger = new Logger(DomainScannerService.name);
private readonly scannerBin = 'RealiTLScanner-linux-64';
private readonly scannerBin =
process.env.SCANNER_BIN || 'RealiTLScanner-linux-64';
private isScanRunning = false;
private readonly logTailLimit = 8000;
private activeScan: ActiveScanState | null = null;
private lastScanResult: ScanResult | null = null;
getCapabilities() {
const scannerCheck = spawnSync('sh', ['-lc', `command -v ${this.scannerBin}`], { encoding: 'utf-8' });
const timeoutCheck = spawnSync('sh', ['-lc', 'command -v timeout'], { encoding: 'utf-8' });
const scannerCheck = spawnSync(
'sh',
['-lc', `command -v ${this.scannerBin}`],
{ encoding: 'utf-8' },
);
const timeoutCheck = spawnSync('sh', ['-lc', 'command -v timeout'], {
encoding: 'utf-8',
});
return {
scannerAvailable: scannerCheck.status === 0,
@@ -72,7 +87,9 @@ export class DomainScannerService {
startedAt: active ? new Date(active.startedAtMs).toISOString() : null,
endsAt: active ? new Date(active.endsAtMs).toISOString() : null,
now: new Date(nowMs).toISOString(),
remainingSeconds: active ? Math.max(0, Math.ceil((active.endsAtMs - nowMs) / 1000)) : 0,
remainingSeconds: active
? Math.max(0, Math.ceil((active.endsAtMs - nowMs) / 1000))
: 0,
foundCount: active?.foundCount ?? 0,
lastRunId: this.lastScanResult?.runId ?? null,
lastFinishedAt: this.lastScanResult?.finishedAt ?? null,
@@ -104,10 +121,14 @@ export class DomainScannerService {
const capabilities = this.getCapabilities();
if (!capabilities.scannerAvailable) {
throw new ServiceUnavailableException(`Не найден ${this.scannerBin} в контейнере`);
throw new ServiceUnavailableException(
`Не найден ${this.scannerBin} в контейнере`,
);
}
if (!capabilities.timeoutAvailable) {
throw new ServiceUnavailableException('Не найдена утилита timeout в контейнере');
throw new ServiceUnavailableException(
'Не найдена утилита timeout в контейнере',
);
}
const args = [
@@ -128,7 +149,9 @@ export class DomainScannerService {
const startedAtMs = Date.now();
const endsAtMs = startedAtMs + scanSeconds * 1000;
this.logger.log(`Starting scanner: addr=${addr}, seconds=${scanSeconds}, thread=${thread}, timeout=${connectTimeout}`);
this.logger.debug(
`Starting scanner: addr=${addr}, seconds=${scanSeconds}, thread=${thread}, timeout=${connectTimeout}`,
);
this.isScanRunning = true;
this.activeScan = {
@@ -178,7 +201,9 @@ export class DomainScannerService {
child.on('close', (code) => resolve(code ?? -1));
}).catch((error: NodeJS.ErrnoException) => {
this.logger.error(`Scanner process failed to start: ${error.message}`);
throw new ServiceUnavailableException(`Не удалось запустить сканер: ${error.message}`);
throw new ServiceUnavailableException(
`Не удалось запустить сканер: ${error.message}`,
);
});
if (stdoutRemainder) {
@@ -190,8 +215,12 @@ export class DomainScannerService {
const timedOut = exitCode === 124 || exitCode === 137 || exitCode === 143;
if (exitCode !== 0 && !timedOut) {
this.logger.error(`Scanner failed, code=${exitCode}, stderr=${stderr.slice(-1200)}`);
throw new InternalServerErrorException(`Сканер завершился с ошибкой (code=${exitCode})`);
this.logger.error(
`Scanner failed, code=${exitCode}, stderr=${stderr.slice(-1200)}`,
);
throw new InternalServerErrorException(
`Сканер завершился с ошибкой (code=${exitCode})`,
);
}
const sortedDomains = [...domains].sort();
@@ -248,7 +277,12 @@ export class DomainScannerService {
return cleaned;
}
private clampNumber(value: number | undefined, fallback: number, min: number, max: number) {
private clampNumber(
value: number | undefined,
fallback: number,
min: number,
max: number,
) {
const num = Number.isFinite(value) ? Number(value) : fallback;
if (num < min) return min;
if (num > max) return max;
@@ -275,7 +309,9 @@ export class DomainScannerService {
// Reject URL-like input to avoid ambiguous parsing.
if (/^[a-z]+:\/\//i.test(value) || /[/?#]/.test(value)) {
throw new BadRequestException('Укажите только IP или hostname без схемы и пути');
throw new BadRequestException(
'Укажите только IP или hostname без схемы и пути',
);
}
// Support common copy-paste format: [IPv6]
@@ -287,11 +323,17 @@ export class DomainScannerService {
throw new BadRequestException('Некорректный addr');
}
if (value === 'localhost' || isIP(value) > 0 || this.isValidHostname(value)) {
if (
value === 'localhost' ||
isIP(value) > 0 ||
this.isValidHostname(value)
) {
return value;
}
throw new BadRequestException('Некорректный addr: укажите IPv4/IPv6 или hostname');
throw new BadRequestException(
'Некорректный addr: укажите IPv4/IPv6 или hostname',
);
}
private isValidHostname(hostname: string) {
+21 -8
View File
@@ -1,4 +1,12 @@
import { Controller, Get, Post, Body, Param, Delete, Query } from '@nestjs/common';
import {
Controller,
Get,
Post,
Body,
Param,
Delete,
Query,
} from '@nestjs/common';
import { DomainsService } from './domains.service';
import { DomainScannerService } from './domain-scanner.service';
@@ -7,7 +15,7 @@ export class DomainsController {
constructor(
private readonly domainsService: DomainsService,
private readonly domainScannerService: DomainScannerService,
) { }
) {}
@Post()
create(@Body() body: { name: string }) {
@@ -35,20 +43,25 @@ export class DomainsController {
}
@Post('scan/start')
startScan(@Body() body: { addr: string; scanSeconds?: number; thread?: number; timeout?: number }) {
startScan(
@Body()
body: {
addr: string;
scanSeconds?: number;
thread?: number;
timeout?: number;
},
) {
return this.domainScannerService.startScan(body);
}
@Get('all')
findAllWithoutPagination() {
return this.domainsService.findAllUnpaginated();
return this.domainsService.findAllUnpaginated();
}
@Get()
findAll(
@Query('page') page: number,
@Query('limit') limit: number
) {
findAll(@Query('page') page: number, @Query('limit') limit: number) {
const pageNum = page ? +page : 1;
const limitNum = limit ? +limit : 10;
+19 -15
View File
@@ -8,7 +8,7 @@ export class DomainsService implements OnModuleInit {
constructor(
@InjectRepository(Domain)
private repo: Repository<Domain>,
) { }
) {}
async onModuleInit() {
await this.seedDefaultDomains();
@@ -16,9 +16,8 @@ export class DomainsService implements OnModuleInit {
private async seedDefaultDomains() {
const count = await this.repo.count();
if (count === 0) {
const defaultDomains = [
'ya.ru',
'vk.com',
@@ -29,11 +28,11 @@ export class DomainsService implements OnModuleInit {
'vkvideo.ru',
'rutube.ru',
'kinopoisk.ru',
'avito.ru'
'avito.ru',
];
const entities = defaultDomains.map(name => this.repo.create({ name }));
await this.repo.save(entities);
const entities = defaultDomains.map((name) => this.repo.create({ name }));
await this.repo.save(entities);
}
}
@@ -93,14 +92,15 @@ export class DomainsService implements OnModuleInit {
.filter((name): name is string => Boolean(name));
const existing = await this.repo.find();
const existingSet = new Set(existing.map(d => d.name.toLowerCase()));
const existingSet = new Set(existing.map((d) => d.name.toLowerCase()));
const uniqueNewNames = [...new Set(cleanNames)]
.filter(name => !existingSet.has(name.toLowerCase()));
const uniqueNewNames = [...new Set(cleanNames)].filter(
(name) => !existingSet.has(name.toLowerCase()),
);
if (uniqueNewNames.length === 0) return { count: 0 };
const entities = uniqueNewNames.map(name => this.repo.create({ name }));
const entities = uniqueNewNames.map((name) => this.repo.create({ name }));
await this.repo.save(entities);
return { count: entities.length };
@@ -134,7 +134,10 @@ export class DomainsService implements OnModuleInit {
}
// Wildcard entries are valid for input UX, but in whitelist storage we keep root form.
value = value.replace(/^\*+\./, '').replace(/^\.+/, '').replace(/\.+$/, '');
value = value
.replace(/^\*+\./, '')
.replace(/^\.+/, '')
.replace(/\.+$/, '');
if (!value) return null;
return this.isValidDomain(value) ? value : null;
@@ -147,10 +150,11 @@ export class DomainsService implements OnModuleInit {
const parts = domain.split('.');
if (parts.length < 2) return false;
return parts.every((part) =>
/^[a-z0-9-]{1,63}$/.test(part)
&& !part.startsWith('-')
&& !part.endsWith('-'),
return parts.every(
(part) =>
/^[a-z0-9-]{1,63}$/.test(part) &&
!part.startsWith('-') &&
!part.endsWith('-'),
);
}
}
+1 -1
View File
@@ -10,4 +10,4 @@ export class Domain {
@Column({ default: true })
isEnabled: boolean;
}
}
@@ -23,4 +23,4 @@ export class Inbound {
@ManyToOne(() => Subscription, (sub) => sub.inbounds, { onDelete: 'CASCADE' })
subscription: Subscription;
}
}
+337 -215
View File
@@ -2,12 +2,23 @@ import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs';
import {
XuiInboundRaw,
XuiInboundSettings,
XuiStreamSettings,
} from './xui-inbound.types';
@Injectable()
export class InboundBuilderService {
private flag = process.env.COUNTRY_FLAG ?? '%F0%9F%92%AF';
buildVlessRealityTcp(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
buildVlessRealityTcp(params: {
port: number;
uuid: string;
sni: string;
privateKey: string;
publicKey: string;
}) {
const { port, uuid, sni, privateKey, publicKey } = params;
return {
enable: true,
@@ -15,10 +26,23 @@ export class InboundBuilderService {
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 }],
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: []
fallbacks: [],
}),
streamSettings: JSON.stringify({
network: 'tcp',
@@ -31,16 +55,35 @@ export class InboundBuilderService {
dest: `${sni}:443`,
serverNames: [sni],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
shortIds: [
crypto.randomBytes(4).toString('hex'),
crypto.randomBytes(4).toString('hex'),
],
settings: {
publicKey: publicKey,
fingerprint: 'random',
serverName: '',
spiderX: '/',
},
},
tcpSettings: { acceptProxyProtocol: false, header: { type: 'none' } }
tcpSettings: { acceptProxyProtocol: false, header: { type: 'none' } },
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false,
}),
sniffing: JSON.stringify({ enabled: false, destOverride: ['http', 'tls', 'quic', 'fakedns'], metadataOnly: false, routeOnly: false })
};
}
buildVlessRealityXhttp(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
buildVlessRealityXhttp(params: {
port: number;
uuid: string;
sni: string;
privateKey: string;
publicKey: string;
}) {
const { port, uuid, sni, privateKey, publicKey } = params;
return {
enable: true,
@@ -48,10 +91,23 @@ export class InboundBuilderService {
protocol: 'vless',
remark: `vless-xhttp-reality`,
settings: JSON.stringify({
clients: [{ id: uuid, flow: '', email: uuid, enable: true, limitIp: 0, totalGB: 0, expiryTime: 0, tgId: '', subId: '', reset: 0 }],
clients: [
{
id: uuid,
flow: '',
email: uuid,
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: '',
subId: '',
reset: 0,
},
],
decryption: 'none',
encryption: 'none',
fallbacks: []
fallbacks: [],
}),
streamSettings: JSON.stringify({
network: 'xhttp',
@@ -64,56 +120,72 @@ export class InboundBuilderService {
dest: `${sni}:443`,
serverNames: [sni],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex'), crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
shortIds: [
crypto.randomBytes(4).toString('hex'),
crypto.randomBytes(4).toString('hex'),
],
settings: {
publicKey: publicKey,
fingerprint: 'random',
serverName: '',
spiderX: '/',
},
},
xhttpSettings: {
host: sni,
path: "/",
mode: "auto",
path: '/',
mode: 'auto',
noSSEHeader: false,
scMaxBufferedPosts: 30,
scMaxEachPostBytes: "1000000",
scStreamUpServerSecs: "20-80",
xPaddingBytes: "100-1000"
}
scMaxEachPostBytes: '1000000',
scStreamUpServerSecs: '20-80',
xPaddingBytes: '100-1000',
},
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false
})
routeOnly: false,
}),
};
}
buildVlessRealityGrpc(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
buildVlessRealityGrpc(params: {
port: number;
uuid: string;
sni: string;
privateKey: string;
publicKey: string;
}) {
const { port, uuid, sni, privateKey, publicKey } = params;
return {
enable: true,
port,
protocol: "vless",
remark: "vless-grpc-reality",
protocol: 'vless',
remark: 'vless-grpc-reality',
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
decryption: "none",
encryption: "none",
fallbacks: []
clients: [
{
id: uuid,
email: uuid,
enable: true,
flow: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: '',
subId: '',
reset: 0,
},
],
decryption: 'none',
encryption: 'none',
fallbacks: [],
}),
streamSettings: JSON.stringify({
network: "grpc",
security: "reality",
network: 'grpc',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
@@ -123,20 +195,25 @@ export class InboundBuilderService {
serverNames: [sni],
privateKey: privateKey,
shortIds: [crypto.randomBytes(4).toString('hex')],
settings: { publicKey: publicKey, fingerprint: 'random', serverName: '', spiderX: '/' }
settings: {
publicKey: publicKey,
fingerprint: 'random',
serverName: '',
spiderX: '/',
},
},
grpcSettings: {
serviceName: "myservice",
serviceName: 'myservice',
authority: sni,
multiMode: false,
}
},
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false
})
routeOnly: false,
}),
};
}
@@ -148,39 +225,41 @@ export class InboundBuilderService {
protocol: 'vless',
remark: `vless-ws`,
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
decryption: "none",
encryption: "none",
fallbacks: []
clients: [
{
id: uuid,
email: uuid,
enable: true,
flow: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: '',
subId: '',
reset: 0,
},
],
decryption: 'none',
encryption: 'none',
fallbacks: [],
}),
streamSettings: JSON.stringify({
network: "ws",
security: "none",
network: 'ws',
security: 'none',
externalProxy: [],
wsSettings: {
host: sni,
path: "/",
path: '/',
acceptProxyProtocol: false,
heartbeatPeriod: 0,
}
},
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false
})
routeOnly: false,
}),
};
}
@@ -192,34 +271,36 @@ export class InboundBuilderService {
protocol: 'vmess',
remark: 'vmess-tcp',
settings: JSON.stringify({
clients: [{
id: uuid,
flow: "",
email: uuid,
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "0",
alterId: "0",
reset: 0
}],
clients: [
{
id: uuid,
flow: '',
email: uuid,
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: '',
subId: '0',
alterId: '0',
reset: 0,
},
],
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "none",
network: 'tcp',
security: 'none',
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
header: { type: 'none' },
},
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false
})
routeOnly: false,
}),
};
}
@@ -231,42 +312,50 @@ export class InboundBuilderService {
protocol: 'shadowsocks',
remark: 'shadowsocks-tcp',
settings: JSON.stringify({
clients: [{
id: "",
flow: "",
email: uuid,
password: crypto.randomBytes(32).toString("base64"),
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
clients: [
{
id: '',
flow: '',
email: uuid,
password: crypto.randomBytes(32).toString('base64'),
enable: true,
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: '',
subId: '',
reset: 0,
},
],
ivCheck: false,
method: "2022-blake3-aes-256-gcm",
network: "tcp",
password: crypto.randomBytes(32).toString("base64")
method: '2022-blake3-aes-256-gcm',
network: 'tcp',
password: crypto.randomBytes(32).toString('base64'),
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "none",
network: 'tcp',
security: 'none',
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
header: { type: 'none' },
},
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false
})
routeOnly: false,
}),
};
}
buildTrojanRealityTcp(params: { port: number; uuid: string; sni: string; privateKey: string; publicKey: string }) {
buildTrojanRealityTcp(params: {
port: number;
uuid: string;
sni: string;
privateKey: string;
publicKey: string;
}) {
const { port, uuid, sni, privateKey, publicKey } = params;
return {
enable: true,
@@ -274,24 +363,26 @@ export class InboundBuilderService {
protocol: 'trojan',
remark: `trojan-tcp-reality`,
settings: JSON.stringify({
clients: [{
id: uuid,
email: uuid,
password: crypto.randomBytes(8).toString("hex"),
enable: true,
flow: "",
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: "",
subId: "",
reset: 0
}],
fallbacks: []
clients: [
{
id: uuid,
email: uuid,
password: crypto.randomBytes(8).toString('hex'),
enable: true,
flow: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
tgId: '',
subId: '',
reset: 0,
},
],
fallbacks: [],
}),
streamSettings: JSON.stringify({
network: "tcp",
security: "reality",
network: 'tcp',
security: 'reality',
externalProxy: [],
realitySettings: {
show: false,
@@ -301,33 +392,33 @@ export class InboundBuilderService {
serverNames: [sni],
privateKey: privateKey,
shortIds: [
crypto.randomBytes(4).toString("hex"),
crypto.randomBytes(3).toString("hex"),
crypto.randomBytes(8).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(2).toString("hex"),
crypto.randomBytes(4).toString("hex")
crypto.randomBytes(4).toString('hex'),
crypto.randomBytes(3).toString('hex'),
crypto.randomBytes(8).toString('hex'),
crypto.randomBytes(2).toString('hex'),
crypto.randomBytes(2).toString('hex'),
crypto.randomBytes(2).toString('hex'),
crypto.randomBytes(2).toString('hex'),
crypto.randomBytes(4).toString('hex'),
],
settings: {
publicKey: publicKey,
fingerprint: "random",
serverName: "",
spiderX: "/"
}
fingerprint: 'random',
serverName: '',
spiderX: '/',
},
},
tcpSettings: {
acceptProxyProtocol: false,
header: { type: "none" }
}
header: { type: 'none' },
},
}),
sniffing: JSON.stringify({
enabled: false,
destOverride: ["http", "tls", "quic", "fakedns"],
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false
})
routeOnly: false,
}),
};
}
@@ -335,21 +426,26 @@ export class InboundBuilderService {
return uuidv4();
}
buildInboundLink(inbound: any, sni: string, idOrPass: string, flagEmoji: string): string {
buildInboundLink(
inbound: XuiInboundRaw,
sni: string,
idOrPass: string,
flagEmoji: string,
): string {
this.flag = flagEmoji;
let link = "";
let link = '';
switch (inbound.protocol) {
case "vless":
case 'vless':
link = this.buildVlessLink(inbound, sni, idOrPass);
break;
case "vmess":
case 'vmess':
link = this.buildVmessLink(inbound, sni, idOrPass);
break;
case "shadowsocks":
case 'shadowsocks':
link = this.buildSsLink(inbound, sni, idOrPass);
break;
case "trojan":
case 'trojan':
link = this.buildTrojanLink(inbound, sni, idOrPass);
break;
}
@@ -357,114 +453,133 @@ export class InboundBuilderService {
return link;
}
private buildVlessLink(inbound: any, sni: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings);
const settings = JSON.parse(inbound.settings);
private buildVlessLink(inbound: XuiInboundRaw, sni: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings) as XuiStreamSettings;
const settings = JSON.parse(inbound.settings) as XuiInboundSettings;
const network = stream.network;
const security = stream.security || "none";
const security = stream.security || 'none';
const params = new URLSearchParams();
params.set("type", network);
params.set("encryption", "none");
params.set("security", security);
params.set('type', network);
params.set('encryption', 'none');
params.set('security', security);
if (security === "reality") {
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 (!r) return '';
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") {
if (network === 'tcp') {
const client = settings.clients?.[0];
if (client?.flow) {
params.set("flow", 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 === 'xhttp') {
const x =
(
stream as {
xhttpSettings?: { path?: string; host?: string; mode?: string };
}
).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 === 'grpc') {
const g =
(
stream as {
grpcSettings?: { serviceName?: string; authority?: string };
}
).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 (network === 'ws') {
const ws =
(
stream as {
wsSettings?: { path?: string; headers?: { Host?: string } };
}
).wsSettings || {};
params.set('path', ws.path || '/');
if (ws.headers?.Host) {
params.set("host", ws.headers.Host);
params.set('host', ws.headers.Host);
}
}
return (
`vless://${uuid}@${sni}:${inbound.port}` +
`?${params.toString()}` +
`#${this.flag}%20${encodeURIComponent(inbound.remark)}`
`#${this.flag}%20${encodeURIComponent(inbound.remark || '')}`
);
}
private buildVmessLink(inbound: any, sni: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings);
private buildVmessLink(inbound: XuiInboundRaw, sni: string, uuid: string) {
const stream = JSON.parse(inbound.streamSettings) as XuiStreamSettings;
const vmessObj = {
add: sni,
aid: '0',
alpn: "",
fp: "",
host: "",
alpn: '',
fp: '',
host: '',
id: uuid,
net: stream.network || "tcp",
path: "/",
net: stream.network || 'tcp',
path: '/',
port: inbound.port.toString(),
ps: decodeURIComponent(this.flag) + ' ' + inbound.remark,
scy: "",
sni: "",
tls: stream.security || "none",
type: "none",
v: "2"
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");
const base64 = Buffer.from(JSON.stringify(vmessObj), 'utf8').toString(
'base64',
);
return `vmess://${base64}`;
}
private buildSsLink(inbound: any, sni: string, idOrPass: string) {
const settings = JSON.parse(inbound.settings);
private buildSsLink(inbound: XuiInboundRaw, sni: string, _idOrPass: string) {
const settings = JSON.parse(inbound.settings) as XuiInboundSettings;
const method = settings.method;
const serverPassword = settings.password;
const clientPassword = settings.clients[0].password;
const method = settings.method || '';
const serverPassword = settings.password || '';
const clientPassword = settings.clients?.[0]?.password || '';
const userInfo = `${method}:${serverPassword}:${clientPassword}`;
const base64 = Buffer
.from(userInfo, "utf8")
.toString("base64");
const base64 = Buffer.from(userInfo, 'utf8').toString('base64');
return `ss://${base64}@${sni}:${inbound.port}?type=tcp#${this.flag}%20${inbound.remark}`;
return `ss://${base64}@${sni}:${inbound.port}?type=tcp#${this.flag}%20${inbound.remark || ''}`;
}
private buildTrojanLink(inbound: any, sni: string, password: string) {
const stream = JSON.parse(inbound.streamSettings);
private buildTrojanLink(
inbound: XuiInboundRaw,
sni: string,
password: string,
) {
const stream = JSON.parse(inbound.streamSettings) as XuiStreamSettings;
const reality = stream.realitySettings;
if (!reality) return '';
const pbk = reality.settings.publicKey;
const pbk = reality.settings?.publicKey || '';
const SNI = reality.serverNames?.[0] || sni;
const sid = reality.shortIds?.[0] || "";
const sid = reality.shortIds?.[0] || '';
const spx = '%2F';
return (
@@ -476,18 +591,23 @@ export class InboundBuilderService {
`&sni=${SNI}` +
`&sid=${sid}` +
`&spx=${spx}` +
`#${this.flag}%20${inbound.remark}`
`#${this.flag}%20${inbound.remark || ''}`
);
}
buildHysteria2Link(serverAddress: string, sni: string, remark: string): string {
buildHysteria2Link(
serverAddress: string,
sni: string,
remark: string,
): string {
let auth = 'YOUR_AUTH';
let obfs = 'salamander';
let obfsPass = 'YOUR_PASS';
let port = 443;
try {
const configPath = '/etc/hysteria/config.yaml';
const configPath =
process.env.HYSTERIA_CONFIG_PATH || '/etc/hysteria/config.yaml';
if (fs.existsSync(configPath)) {
const fileContent = fs.readFileSync(configPath, 'utf8');
@@ -498,7 +618,9 @@ export class InboundBuilderService {
const obfsMatch = fileContent.match(/type:\s*['"]?(salamander)['"]?/);
if (obfsMatch) obfs = obfsMatch[1];
const passMatch = fileContent.match(/salamander:[\s\S]*?password:\s*['"]?([^'"\n]+)['"]?/);
const passMatch = fileContent.match(
/salamander:[\s\S]*?password:\s*['"]?([^'"\n]+)['"]?/,
);
if (passMatch) obfsPass = passMatch[1];
const listenMatch = fileContent.match(/listen:\s*['"]?:(\d+)['"]?/);
@@ -518,4 +640,4 @@ export class InboundBuilderService {
return `hy2://${auth}@${serverAddress}:${port}/?${params.toString()}#${remark}`;
}
}
}
+1 -1
View File
@@ -10,4 +10,4 @@ export const CONNECTION_TYPES = [
'custom',
] as const;
export type ConnectionType = typeof CONNECTION_TYPES[number];
export type ConnectionType = (typeof CONNECTION_TYPES)[number];
+1 -1
View File
@@ -8,4 +8,4 @@ import { InboundBuilderService } from './inbound-builder.service';
providers: [InboundBuilderService],
exports: [InboundBuilderService],
})
export class InboundsModule {}
export class InboundsModule {}
+64
View File
@@ -0,0 +1,64 @@
export interface XuiInboundRaw {
id?: number;
enable?: boolean;
port: number;
protocol: string;
settings: string; // JSON string
streamSettings: string; // JSON string
remark?: string;
}
export interface XuiInboundSettings {
clients?: Array<{
id?: string;
password?: string;
email?: string;
flow?: string;
enable?: boolean;
limitIp?: number;
totalGB?: number;
expiryTime?: number;
tgId?: string;
subId?: string;
reset?: number;
}>;
decryption?: string;
encryption?: string;
fallbacks?: unknown[];
method?: string;
password?: string;
}
export interface XuiStreamSettings {
network: string;
security?: string;
externalProxy?: unknown[];
realitySettings?: {
show: boolean;
xver: number;
target: string;
dest: string;
serverNames: string[];
privateKey: string;
shortIds: string[];
settings?: {
publicKey: string;
fingerprint: string;
};
};
wsSettings?: {
path: string;
headers?: {
Host?: string;
};
};
grpcSettings?: {
serviceName: string;
authority?: string;
};
xhttpSettings?: {
path: string;
host?: string;
mode?: string;
};
}
+25 -6
View File
@@ -2,24 +2,43 @@ import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { AuthService } from './auth/auth.service';
import { RequestMethod } from '@nestjs/common';
import { RequestMethod, Logger, LogLevel } from '@nestjs/common';
import { Request, Response } from 'express';
import { HttpExceptionFilter } from './client/client.exception-filter';
import { ConfigService } from '@nestjs/config';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService);
const logger = new Logger('Bootstrap');
// Настройка уровня логирования из переменной окружения
const configuredLevel = configService.get<string>('LOG_LEVEL', 'error');
const logLevels: LogLevel[] =
configuredLevel === 'debug'
? ['error', 'warn', 'log', 'debug']
: configuredLevel === 'verbose'
? ['error', 'warn', 'log', 'debug', 'verbose']
: ['error', 'warn', 'log'];
app.useLogger(logLevels);
app.set('trust proxy', 1);
const authService = app.get(AuthService);
await authService.seedAdmin();
app.enableCors();
app.useGlobalFilters(new HttpExceptionFilter());
app.setGlobalPrefix('api', {
exclude: [
{ path: 'bus/:uuid', method: RequestMethod.GET },
{ path: 'bus/:uuid/:tunnelId', method: RequestMethod.GET },
]
],
});
await app.listen(3000);
const port = configService.get<number>('PORT', 3000);
await app.listen(port);
logger.log(`Application started on port ${port}`);
}
bootstrap();
void bootstrap();
+1 -1
View File
@@ -9,4 +9,4 @@ export class RotationController {
async rotateAll() {
return this.rotationService.performRotation();
}
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ import { RotationController } from './rotation.controller';
providers: [RotationService],
controllers: [RotationController],
})
export class RotationModule {}
export class RotationModule {}
+136 -37
View File
@@ -10,6 +10,7 @@ import { Setting } from '../settings/entities/setting.entity';
import { XuiService } from '../xui/xui.service';
import { InboundBuilderService } from '../inbounds/inbound-builder.service';
import { XuiInboundRaw } from '../inbounds/xui-inbound.types';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
@@ -30,38 +31,87 @@ export class RotationService implements OnModuleInit {
}
private async initDefaultSettings() {
const key = 'rotation_status';
const existing = await this.settingRepo.findOne({ where: { key } });
const statusKey = 'rotation_status';
const intervalKey = 'rotation_interval';
const lastRunKey = 'last_rotation_timestamp';
if (!existing) {
this.logger.log(`Инициализация настройки: ${key} = active`);
// Инициализация статуса ротации
const existingStatus = await this.settingRepo.findOne({
where: { key: statusKey },
});
if (!existingStatus) {
this.logger.debug(`Инициализация настройки: ${statusKey} = active`);
const newSetting = this.settingRepo.create({
key: key,
key: statusKey,
value: 'active',
});
await this.settingRepo.save(newSetting);
} else {
this.logger.log(`Текущий статус ротации: ${existing.value}`);
this.logger.debug(`Текущий статус ротации: ${existingStatus.value}`);
}
// Инициализация интервала ротации (по умолчанию 30 минут)
const existingInterval = await this.settingRepo.findOne({
where: { key: intervalKey },
});
if (!existingInterval) {
this.logger.debug(`Инициализация настройки: ${intervalKey} = 30`);
const newSetting = this.settingRepo.create({
key: intervalKey,
value: '30',
});
await this.settingRepo.save(newSetting);
}
// Инициализация last_rotation_timestamp (текущее время, чтобы не было ложной ротации при старте)
const existingLastRun = await this.settingRepo.findOne({
where: { key: lastRunKey },
});
if (!existingLastRun) {
const now = Date.now();
this.logger.debug(`Инициализация настройки: ${lastRunKey} = ${now}`);
const newSetting = this.settingRepo.create({
key: lastRunKey,
value: now.toString(),
});
await this.settingRepo.save(newSetting);
} else {
this.logger.debug(`Последняя ротация: ${existingLastRun.value}`);
}
}
@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 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 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;
const statusSetting = await this.settingRepo.findOne({ where: { key: 'rotation_status' } });
const statusSetting = await this.settingRepo.findOne({
where: { key: 'rotation_status' },
});
const isStopped = statusSetting?.value === 'stopped';
this.logger.debug(
`Планировщик: интервал=${intervalMinutes}мин, прошло=${diffMinutes.toFixed(1)}мин, статус=${isStopped ? 'stopped' : 'active'}`,
);
if (diffMinutes < intervalMinutes || isStopped) {
return;
}
this.logger.debug(
`Запуск ротации (прошло ${diffMinutes.toFixed(1)}мин при интервале ${intervalMinutes}мин)`,
);
await this.performRotation();
await this.saveSetting('last_rotation_timestamp', now.toString());
@@ -74,8 +124,8 @@ export class RotationService implements OnModuleInit {
await this.settingRepo.save(s);
}
async performRotation() {
this.logger.log('Запуск плановой ротации...');
async performRotation() {
this.logger.debug('Запуск плановой ротации...');
const isLoginSuccess = await this.xuiService.login();
if (!isLoginSuccess) {
@@ -83,7 +133,10 @@ export class RotationService implements OnModuleInit {
return { success: false, message: 'Не удалось войти в панель 3x-ui' };
}
const subscriptions = await this.subRepo.find({ where: { isEnabled: true }, relations: ['inbounds'] });
const subscriptions = await this.subRepo.find({
where: { isEnabled: true },
relations: ['inbounds'],
});
if (subscriptions.length === 0) {
return { success: false, message: 'Нет активных подписок для ротации' };
}
@@ -98,12 +151,12 @@ export class RotationService implements OnModuleInit {
await this.rotateSubscription(sub, domains);
}
this.logger.log('Ротация завершена.');
this.logger.debug('Ротация завершена.');
return { success: true, message: 'Ротация успешно выполнена' };
}
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
this.logger.log(`Ротация для подписки: ${sub.name} (${sub.uuid})`);
private async rotateSubscription(sub: Subscription, domains: Domain[]) {
this.logger.debug(`Ротация для подписки: ${sub.name} (${sub.uuid})`);
// Удаляем старые инбаунды
if (sub.inbounds && sub.inbounds.length > 0) {
@@ -117,14 +170,18 @@ private async rotateSubscription(sub: Subscription, domains: Domain[]) {
const keys = await this.xuiService.getNewX25519Cert();
if (!keys) {
this.logger.error("Не удалось получить Reality ключи, пропускаем подписку");
this.logger.error(
'Не удалось получить Reality ключи, пропускаем подписку',
);
return;
}
const usedPorts = new Set<number>();
const host = await this.settingRepo.findOne({ where: { key: 'xui_host' } });
const serverAddress = host?.value || 'localhost';
const flag = await this.settingRepo.findOne({ where: { key: 'xui_geo_flag' } });
const flag = await this.settingRepo.findOne({
where: { key: 'xui_geo_flag' },
});
const flagEmoji = flag?.value ?? '%F0%9F%92%AF';
// Получаем конфиг или пустой массив
@@ -133,7 +190,7 @@ private async rotateSubscription(sub: Subscription, domains: Domain[]) {
for (const config of inboundsConfig) {
const type = config.type;
const uuid = uuidv4();
let sni = '';
// === 1. Обработка Custom ===
@@ -144,7 +201,7 @@ private async rotateSubscription(sub: Subscription, domains: Domain[]) {
protocol: 'custom',
remark: 'custom-link',
link: config.link || '',
subscription: sub
subscription: sub,
});
await this.inboundRepo.save(newInbound);
continue;
@@ -154,42 +211,64 @@ private async rotateSubscription(sub: Subscription, domains: Domain[]) {
// === 2. Обработка Hysteria2 ===
if (type === 'hysteria2-udp') {
const link = this.inboundBuilder.buildHysteria2Link(serverAddress, sni, flagEmoji + '%20hysteria2-udp');
const link = this.inboundBuilder.buildHysteria2Link(
serverAddress,
sni,
flagEmoji + '%20hysteria2-udp',
);
const newInbound = this.inboundRepo.create({
xuiId: 0,
xuiId: 0,
port: 0, // Обычно Hysteria висит на 443, фактический порт вытаскивается в билдере
protocol: 'hysteria2',
remark: 'hysteria2-udp',
link: link,
subscription: sub
subscription: sub,
});
await this.inboundRepo.save(newInbound);
continue;
}
// === 3. Обработка стандартных инбаундов Xray (3x-ui) ===
// Определяем порт
let port = 0;
if (config.port === 'random' || !config.port) {
port = await this.getFreePort(0, usedPorts);
} else {
// Если передан конкретный порт строкой или числом
port = typeof config.port === 'string' ? parseInt(config.port, 10) : config.port;
port =
typeof config.port === 'string'
? parseInt(config.port, 10)
: config.port;
}
usedPorts.add(port);
let xuiConfig: any;
let xuiConfig: XuiInboundRaw | null = null;
switch (type) {
case 'vless-tcp-reality':
xuiConfig = this.inboundBuilder.buildVlessRealityTcp({ port, uuid, sni, ...keys });
xuiConfig = this.inboundBuilder.buildVlessRealityTcp({
port,
uuid,
sni,
...keys,
});
break;
case 'vless-xhttp-reality':
xuiConfig = this.inboundBuilder.buildVlessRealityXhttp({ port, uuid, sni, ...keys });
xuiConfig = this.inboundBuilder.buildVlessRealityXhttp({
port,
uuid,
sni,
...keys,
});
break;
case 'vless-grpc-reality':
xuiConfig = this.inboundBuilder.buildVlessRealityGrpc({ port, uuid, sni, ...keys });
xuiConfig = this.inboundBuilder.buildVlessRealityGrpc({
port,
uuid,
sni,
...keys,
});
break;
case 'vless-ws':
xuiConfig = this.inboundBuilder.buildVlessWs({ port, uuid, sni });
@@ -201,7 +280,12 @@ private async rotateSubscription(sub: Subscription, domains: Domain[]) {
xuiConfig = this.inboundBuilder.buildShadowsocksTcp({ port, uuid });
break;
case 'trojan-tcp-reality':
xuiConfig = this.inboundBuilder.buildTrojanRealityTcp({ port, uuid, sni, ...keys });
xuiConfig = this.inboundBuilder.buildTrojanRealityTcp({
port,
uuid,
sni,
...keys,
});
break;
default:
this.logger.warn(`Неизвестный тип инбаунда: ${type}`);
@@ -210,9 +294,19 @@ private async rotateSubscription(sub: Subscription, domains: Domain[]) {
const xuiId = await this.xuiService.addInbound(xuiConfig);
if (xuiId) {
const idOrPass = xuiConfig.settings ? JSON.parse(xuiConfig.settings).clients?.[0]?.id || JSON.parse(xuiConfig.settings).clients?.[0]?.password : "";
const fullLink = this.inboundBuilder.buildInboundLink(xuiConfig, serverAddress, idOrPass, flagEmoji);
if (xuiId && xuiConfig) {
const settings = JSON.parse(xuiConfig.settings) as {
clients?: Array<{ id?: string; password?: string }>;
};
const idOrPass =
settings.clients?.[0]?.id || settings.clients?.[0]?.password || '';
const fullLink = this.inboundBuilder.buildInboundLink(
xuiConfig,
serverAddress,
idOrPass,
flagEmoji,
);
const newInbound = this.inboundRepo.create({
xuiId: xuiId,
@@ -220,7 +314,7 @@ private async rotateSubscription(sub: Subscription, domains: Domain[]) {
protocol: xuiConfig.protocol,
remark: xuiConfig.remark,
link: fullLink,
subscription: sub
subscription: sub,
});
await this.inboundRepo.save(newInbound);
}
@@ -231,9 +325,14 @@ private async rotateSubscription(sub: Subscription, domains: Domain[]) {
return list[Math.floor(Math.random() * list.length)].name;
}
private async getFreePort(preferred: number, currentBatch: Set<number>): Promise<number> {
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 } });
const exists = await this.inboundRepo.findOne({
where: { port: preferred },
});
if (!exists) return preferred;
}
@@ -245,4 +344,4 @@ private async rotateSubscription(sub: Subscription, domains: Domain[]) {
if (!exists) return p;
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { SessionService } from './session.service';
@Global()
@Module({
providers: [SessionService],
exports: [SessionService],
})
export class SessionModule {}
+48
View File
@@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
/**
* Сервис для управления сессионными cookie
* Хранит и предоставляет cookie для HTTP-запросов к внешним API
*/
@Injectable()
export class SessionService {
private readonly logger = new Logger(SessionService.name);
private cookie: string | null = null;
/**
* Получить текущую сессионную cookie
*/
getCookie(): string | null {
return this.cookie;
}
/**
* Установить сессионную cookie из заголовков ответа
* @param setCookieHeader Массив заголовков Set-Cookie
*/
setFromHeaders(setCookieHeader: string[] | undefined): void {
if (!setCookieHeader) {
this.logger.warn('Set-Cookie заголовок отсутствует');
return;
}
this.cookie = setCookieHeader.map((c) => c.split(';')[0]).join('; ');
this.logger.debug('Сессионная cookie обновлена');
}
/**
* Очистить сессионную cookie
*/
clear(): void {
this.cookie = null;
this.logger.debug('Сессионная cookie очищена');
}
/**
* Проверить наличие сессионной cookie
*/
hasCookie(): boolean {
return this.cookie !== null && this.cookie.length > 0;
}
}
File diff suppressed because it is too large Load Diff
@@ -10,4 +10,4 @@ export class Setting {
@Column({ nullable: true })
description: string;
}
}
+49 -22
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body } from '@nestjs/common';
import { Controller, Get, Post, Body, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Setting } from './entities/setting.entity';
@@ -9,29 +9,40 @@ import { XuiService } from 'src/xui/xui.service';
@Controller('settings')
export class SettingsController {
private readonly logger = new Logger(SettingsController.name);
constructor(
@InjectRepository(Setting)
private settingsRepo: Repository<Setting>,
private xuiService: XuiService
private xuiService: XuiService,
) {}
@Get()
async findAll() {
const settings = await this.settingsRepo.find();
return settings.reduce((acc, curr) => ({ ...acc, [curr.key]: curr.value }), {});
return settings.reduce(
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
{},
);
}
@Post('check')
async checkConnection(@Body() body: { xui_url: string; xui_login: string; xui_password: string }) {
const success = await this.xuiService.checkConnection(body.xui_url, body.xui_login, body.xui_password);
async checkConnection(
@Body() body: { xui_url: string; xui_login: string; xui_password: string },
) {
const success = await this.xuiService.checkConnection(
body.xui_url,
body.xui_login,
body.xui_password,
);
return { success };
}
@Post()
async update(@Body() settings: Record<string, string>) {
async update(@Body() settings: Record<string, string>) {
if (settings.xui_url) {
try {
const parsed = new URL(settings.xui_url);
const parsed = new URL(settings.xui_url);
settings['xui_host'] = parsed.hostname;
let address = '';
@@ -41,47 +52,63 @@ export class SettingsController {
} else {
address = parsed.hostname;
}
settings['xui_ip'] = address;
console.log(`Extracted host: ${parsed.hostname} from ${settings.xui_url}`);
this.logger.log(
`Extracted host: ${parsed.hostname} from ${settings.xui_url}`,
);
if (address && address !== '127.0.0.1' && address !== 'localhost') {
try {
console.log(`Определяем страну для IP: ${address}...`);
this.logger.log(`Определяем страну для IP: ${address}...`);
const geoRes = await fetch(`http://ip-api.com/json/${address}`);
const geoData: any = await geoRes.json();
const geoData = (await geoRes.json()) as {
status: string;
countryCode?: string;
country?: string;
message?: string;
};
if (geoData.status === 'success') {
const countryCode = geoData.countryCode;
const countryInfo = COUNTRIES.find(c => c.code === countryCode);
const countryInfo = COUNTRIES.find((c) => c.code === countryCode);
if (countryInfo) {
const flagEmoji = countryInfo.emoji;
settings['xui_geo_country'] = countryInfo.name;
settings['xui_geo_flag'] = flagEmoji;
console.log(`GeoIP Success: ${countryInfo.name} ${flagEmoji}`);
this.logger.log(
`GeoIP Success: ${countryInfo.name} ${flagEmoji}`,
);
} else {
console.warn(`Страна с кодом ${countryCode} не найдена в countries.ts`);
this.logger.warn(
`Страна с кодом ${countryCode} не найдена в countries.ts`,
);
settings['xui_geo_country'] = geoData.country;
settings['xui_geo_flag'] = '';
}
} else {
console.warn(`GeoIP Error: ${geoData.message}`);
this.logger.warn(
`GeoIP Error: ${(geoData as { message?: string }).message}`,
);
}
} catch (geoError) {
console.error(`Ошибка запроса к ip-api.com: ${geoError.message}`);
this.logger.error(
`Ошибка запроса к ip-api.com: ${(geoError as Error).message}`,
);
}
}
} catch (e) {
console.warn(`Не удалось извлечь хост из URL: ${settings.xui_url}`);
} catch {
this.logger.warn(`Не удалось извлечь хост из URL: ${settings.xui_url}`);
}
}
for (const [key, value] of Object.entries(settings)) {
await this.settingsRepo.save({ key, value });
}
this.logger.log('Settings saved to database');
return { success: true };
}
}
}
+1 -1
View File
@@ -8,4 +8,4 @@ import { XuiModule } from 'src/xui/xui.module';
imports: [TypeOrmModule.forFeature([Setting]), XuiModule],
controllers: [SettingsController],
})
export class SettingsModule {}
export class SettingsModule {}
@@ -1,4 +1,11 @@
import { IsString, IsArray, ValidateNested, IsOptional, Min, Max, ArrayMinSize, ArrayMaxSize } from 'class-validator';
import {
IsString,
IsArray,
ValidateNested,
IsOptional,
ArrayMinSize,
ArrayMaxSize,
} from 'class-validator';
import { Type } from 'class-transformer';
export class InboundConfigDto {
@@ -6,11 +13,11 @@ export class InboundConfigDto {
type: string;
@IsOptional()
port?: number | 'random';
port?: number | string;
@IsString()
@IsOptional()
sni?: string | 'random';
sni?: string;
@IsString()
@IsOptional()
@@ -28,4 +35,4 @@ export class CreateSubscriptionDto {
@ArrayMaxSize(20)
@IsOptional()
inboundsConfig?: InboundConfigDto[];
}
}
@@ -1,4 +1,11 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { Inbound } from '../../inbounds/entities/inbound.entity';
@Entity()
@@ -16,7 +23,12 @@ export class Subscription {
isEnabled: boolean;
@Column({ type: 'simple-json', nullable: true })
inboundsConfig: any[];
inboundsConfig: Array<{
type?: string;
port?: number | string;
sni?: string;
link?: string;
}>;
@OneToMany(() => Inbound, (inbound) => inbound.subscription)
inbounds: Inbound[];
@@ -26,4 +38,4 @@ export class Subscription {
@UpdateDateColumn()
updatedAt: Date;
}
}
@@ -1,4 +1,12 @@
import { Controller, Get, Post, Delete, Body, Param, Put } from '@nestjs/common';
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Put,
} from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
@@ -17,7 +25,10 @@ export class SubscriptionsController {
}
@Put(':id')
update(@Param('id') id: string, @Body() updateSubscriptionDto: CreateSubscriptionDto) {
update(
@Param('id') id: string,
@Body() updateSubscriptionDto: CreateSubscriptionDto,
) {
return this.subscriptionsService.update(id, updateSubscriptionDto);
}
@@ -25,4 +36,4 @@ export class SubscriptionsController {
remove(@Param('id') id: string) {
return this.subscriptionsService.remove(id);
}
}
}
@@ -12,4 +12,4 @@ import { XuiModule } from '../xui/xui.module';
providers: [SubscriptionsService],
exports: [SubscriptionsService],
})
export class SubscriptionsModule {}
export class SubscriptionsModule {}
@@ -15,7 +15,10 @@ export class SubscriptionsService {
) {}
findAll() {
return this.subRepo.find({ relations: ['inbounds'], order: { createdAt: 'DESC' } });
return this.subRepo.find({
relations: ['inbounds'],
order: { createdAt: 'DESC' },
});
}
async create(dto: CreateSubscriptionDto) {
@@ -24,14 +27,14 @@ export class SubscriptionsService {
uuid: uuidv4(),
inboundsConfig: dto.inboundsConfig || [],
});
return this.subRepo.save(sub);
}
async update(id: string, dto: CreateSubscriptionDto) {
const sub = await this.subRepo.findOne({
where: { id },
relations: ['inbounds']
const sub = await this.subRepo.findOne({
where: { id },
relations: ['inbounds'],
});
if (!sub) {
@@ -39,7 +42,7 @@ export class SubscriptionsService {
}
sub.name = dto.name;
if (dto.inboundsConfig) {
sub.inboundsConfig = dto.inboundsConfig;
}
@@ -48,7 +51,10 @@ export class SubscriptionsService {
}
async remove(id: string) {
const sub = await this.subRepo.findOne({ where: { id }, relations: ['inbounds'] });
const sub = await this.subRepo.findOne({
where: { id },
relations: ['inbounds'],
});
if (!sub) return;
if (sub.inbounds && sub.inbounds.length > 0) {
@@ -59,4 +65,4 @@ export class SubscriptionsService {
return this.subRepo.remove(sub);
}
}
}
+1 -1
View File
@@ -28,4 +28,4 @@ export class Tunnel {
@Column({ default: false })
isInstalled: boolean;
}
}
+47 -35
View File
@@ -6,45 +6,57 @@ export class SshService {
private readonly logger = new Logger(SshService.name);
async executeCommand(
config: { host: string; port: number; username: string; password?: string, privateKey?: string },
command: string
config: {
host: string;
port: number;
username: string;
password?: string;
privateKey?: 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();
conn
.on('ready', () => {
this.logger.debug(`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.debug(`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: Buffer) => {
output += data.toString();
})
.stderr.on('data', (data: Buffer) => {
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,
privateKey: config.privateKey,
readyTimeout: 20000,
});
}).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,
privateKey: config.privateKey,
readyTimeout: 20000,
});
});
}
}
}
+1 -1
View File
@@ -25,4 +25,4 @@ export class TunnelsController {
remove(@Param('id') id: string) {
return this.tunnelsService.remove(+id);
}
}
}
+1 -1
View File
@@ -11,4 +11,4 @@ import { SshService } from './ssh.service';
controllers: [TunnelsController],
providers: [TunnelsService, SshService],
})
export class TunnelsModule {}
export class TunnelsModule {}
+35 -22
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, DeepPartial } from 'typeorm';
import { Tunnel } from './entities/tunnel.entity';
import { SshService } from './ssh.service';
import { Setting } from '../settings/entities/setting.entity';
@@ -15,7 +15,7 @@ export class TunnelsService {
private sshService: SshService,
) {}
async create(createTunnelDto: any) {
async create(createTunnelDto: DeepPartial<Tunnel>) {
const tunnel = this.tunnelRepo.create(createTunnelDto);
return this.tunnelRepo.save(tunnel);
}
@@ -29,46 +29,59 @@ export class TunnelsService {
}
async installScript(id: number) {
const tunnel = await this.tunnelRepo.createQueryBuilder('tunnel')
const tunnel = await this.tunnelRepo
.createQueryBuilder('tunnel')
.addSelect('tunnel.password')
.addSelect('tunnel.privateKey')
.where('tunnel.id = :id', { id })
.getOne();
if (!tunnel) throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND);
if (!tunnel)
throw new HttpException('Tunnel not found', HttpStatus.NOT_FOUND);
const hostSetting = await this.settingRepo.findOne({
where: { key: 'xui_ip' },
});
const hostSetting = await this.settingRepo.findOne({ where: { key: 'xui_ip' } });
if (!hostSetting || !hostSetting.value) {
throw new HttpException(
'В настройках (Settings) не сохранен Host/IP основного сервера (xui_host). Сохраните настройки подключения к 3x-ui заново.',
HttpStatus.BAD_REQUEST
'В настройках (Settings) не сохранен Host/IP основного сервера (xui_host). Сохраните настройки подключения к 3x-ui заново.',
HttpStatus.BAD_REQUEST,
);
}
const mainServerIp = hostSetting.value;
this.logger.log(`Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`);
this.logger.debug(
`Начинаем установку редиректа на ${tunnel.ip} -> ${mainServerIp}`,
);
const command = `export ORIGIN_IP="${mainServerIp}" && bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)`;
try {
const output = await this.sshService.executeCommand({
host: tunnel.ip,
port: tunnel.sshPort,
username: tunnel.username,
password: tunnel.password,
privateKey: tunnel.privateKey
}, command);
const output = await this.sshService.executeCommand(
{
host: tunnel.ip,
port: tunnel.sshPort,
username: tunnel.username,
password: tunnel.password,
privateKey: tunnel.privateKey,
},
command,
);
this.logger.debug(`Скрипт выполнен успешно:\n${output}`);
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);
const error = e as Error;
this.logger.error(`Ошибка SSH: ${error.message}`);
throw new HttpException(
`Ошибка установки: ${error.message}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
}
+1 -1
View File
@@ -8,4 +8,4 @@ import { Setting } from '../settings/entities/setting.entity';
providers: [XuiService],
exports: [XuiService],
})
export class XuiModule {}
export class XuiModule {}
+85 -38
View File
@@ -1,19 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import axios, { AxiosInstance } from 'axios';
import axios, { AxiosInstance, AxiosError } from 'axios';
import * as https from 'https';
import { Setting } from '../settings/entities/setting.entity';
import { XuiResponse, XuiCertResult, XuiInboundRaw } from './xui.types';
import { SessionService } from '../session/session.service';
interface LoginResponse {
success: boolean;
}
@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>,
private sessionService: SessionService,
) {
this.api = axios.create({
timeout: 15000,
@@ -22,8 +28,9 @@ export class XuiService {
});
this.api.interceptors.request.use((config) => {
if (this.cookie) {
config.headers['Cookie'] = this.cookie;
const cookie = this.sessionService.getCookie();
if (cookie) {
config.headers['Cookie'] = cookie;
}
return config;
});
@@ -39,116 +46,156 @@ export class XuiService {
async login() {
try {
const config = await this.getSettings();
if (!config['xui_url'] || !config['xui_login'] || !config['xui_password']) {
if (
!config['xui_url'] ||
!config['xui_login'] ||
!config['xui_password']
) {
this.logger.warn('Настройки 3x-ui не заполнены в БД');
return false;
}
this.logger.log(`Attempting login to 3x-ui: ${config['xui_url']}`);
this.api.defaults.baseURL = config['xui_url'];
const res = await this.api.post('/login', {
const res = await this.api.post<LoginResponse>('/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');
this.sessionService.setFromHeaders(res.headers['set-cookie']);
this.logger.log('3x-ui login successful');
return true;
} else {
this.logger.warn('3x-ui login failed: No cookie received');
}
} catch (e) {
this.logger.error(`Ошибка авторизации: ${e.message}`);
const error = e as AxiosError;
this.logger.error(`3x-ui login error: ${error.message}`);
}
return false;
}
async addInbound(inboundConfig: any) {
async addInbound(
inboundConfig: { port: number; [key: string]: unknown } | XuiInboundRaw,
): Promise<number | null> {
let attempts = 0;
const maxAttempts = 3;
this.logger.log(`Adding inbound on port ${inboundConfig.port}`);
while (attempts < maxAttempts) {
attempts++;
try {
const res = await this.api.post('/panel/api/inbounds/add', inboundConfig);
const res = await this.api.post<XuiResponse<{ id: number }>>(
'/panel/api/inbounds/add',
inboundConfig,
);
if (res.data?.success) {
this.logger.log(
`Inbound created successfully with ID: ${res.data.obj.id}`,
);
return res.data.obj.id;
}
else {
} else {
const msg = res.data?.msg || '';
if (
msg.toLowerCase().includes('port') &&
msg.toLowerCase().includes('port') &&
msg.toLowerCase().includes('exists')
) {
this.logger.warn(`Попытка ${attempts}/${maxAttempts}: Порт ${inboundConfig.port} занят. Генерируем новый...`);
inboundConfig.port = Math.floor(Math.random() * (60000 - 10000 + 1) + 10000);
this.logger.warn(
`Попытка ${attempts}/${maxAttempts}: Порт ${inboundConfig.port} занят. Генерируем новый...`,
);
inboundConfig.port = Math.floor(
Math.random() * (60000 - 10000 + 1) + 10000,
);
} else {
this.logger.error(`3x-ui отклонил создание: ${msg}`);
return null;
}
}
} catch (e) {
if (e.response?.status === 401) {
const error = e as AxiosError;
if (error.response?.status === 401) {
this.logger.log('Сессия истекла, пробуем релогин...');
if (await this.login()) {
return this.addInbound(inboundConfig);
}
}
this.logger.error(`Ошибка сети/валидации при добавлении инбаунда: ${e.message}`);
this.logger.error(
`Ошибка сети/валидации при добавлении инбаунда: ${error.message}`,
);
return null;
}
}
this.logger.error(`Не удалось создать инбаунд после ${maxAttempts} попыток смены порта.`);
this.logger.error(
`Не удалось создать инбаунд после ${maxAttempts} попыток смены порта.`,
);
return null;
}
async deleteInbound(id: number) {
try {
await this.api.post(`/panel/api/inbounds/del/${id}`);
this.logger.log(`Инбаунд ${id} удален`);
this.logger.debug(`Инбаунд ${id} удален`);
} catch (e) {
this.logger.error(`Ошибка удаления инбаунда ${id}: ${e.message}`);
const error = e as AxiosError;
this.logger.error(`Ошибка удаления инбаунда ${id}: ${error.message}`);
}
}
async checkConnection(url: string, username: string, pass: string): Promise<boolean> {
async checkConnection(
url: string,
username: string,
pass: string,
): Promise<boolean> {
try {
this.logger.log(`Checking connection to 3x-ui: ${url}`);
const tempApi = axios.create({
baseURL: url,
timeout: 5000,
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
withCredentials: true
withCredentials: true,
});
const res = await tempApi.post('/login', {
const res = await tempApi.post<LoginResponse>('/login', {
username: username,
password: pass,
});
if (res.headers['set-cookie'] && res.data?.success) {
this.logger.log(`Connection to 3x-ui successful: ${url}`);
return true;
} else {
this.logger.warn(
`Connection failed: Invalid credentials or no cookie received`,
);
}
} catch (e) {
this.logger.warn(`Ошибка авторизации: ${e.message}`);
} catch (error) {
const axiosError = error as AxiosError;
this.logger.error(
`Connection error: ${axiosError.message} (URL: ${url})`,
);
}
return false;
}
async getNewX25519Cert() {
async getNewX25519Cert(): Promise<XuiCertResult | null> {
try {
const res = await this.api.get('/panel/api/server/getNewX25519Cert');
if (res.data?.success) return res.data.obj;
} catch (e) {
const res = await this.api.get<XuiResponse<XuiCertResult>>(
'/panel/api/server/getNewX25519Cert',
);
if (res.data?.success && res.data.obj) return res.data.obj;
} catch {
this.logger.error('Ошибка получения ключей Reality');
}
return null;
}
}
}
+65
View File
@@ -0,0 +1,65 @@
export interface XuiResponse<T = unknown> {
success: boolean;
msg?: string;
obj?: T;
}
export interface XuiInbound {
id: number;
enable: boolean;
up: number;
down: number;
total: number;
remark: string;
expiryTime: number;
clientStats: unknown[];
port: number;
protocol: string;
settings: string;
streamSettings: string;
sniffing: string;
listen: string;
}
export interface XuiInboundRaw {
id?: number;
enable?: boolean;
port: number;
protocol: string;
settings: string;
streamSettings: string;
remark?: string;
}
export interface XuiInboundClient {
id?: string;
flow?: string;
email?: string;
limitIp?: number;
totalGB?: number;
expiryTime?: number;
enable?: boolean;
tgId?: string;
subId?: string;
reset?: number;
password?: string;
}
export interface XuiRealitySettings {
show: boolean;
xver: number;
target: string;
dest: string;
serverNames: string[];
privateKey: string;
shortIds: string[];
settings?: {
publicKey: string;
fingerprint: string;
};
}
export interface XuiCertResult {
privateKey: string;
publicKey: string;
}