feat: add network module and service for TCP latency measurement and proxy performance

This commit is contained in:
2026-03-14 17:04:53 +03:00
parent 638940c694
commit 51d26a4c1b
30 changed files with 7992 additions and 1188 deletions

2
web/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

8
web/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

6294
web/api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
web/api/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "vpn-proxy-api",
"version": "1.0.0",
"description": "VPN-Proxy backend API",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ProxyModule } from './proxy/proxy.module';
@Module({
imports: [ProxyModule],
})
export class AppModule {}

View File

@@ -0,0 +1,33 @@
import * as path from 'path';
// After compilation: dist/config/config.js
// __dirname = <web/api>/dist/config
// Go up: dist/config -> dist -> api -> web -> project root
const API_DIR = path.resolve(__dirname, '..', '..');
const WEB_DIR = path.resolve(API_DIR, '..');
const BASE_DIR = path.resolve(WEB_DIR, '..');
const DATA_DIR = path.join(BASE_DIR, 'data');
export const config = {
port: parseInt(process.env.PORT || '3456', 10),
proxyPort: parseInt(process.env.PROXY_PORT || '8080', 10),
reloadPort: parseInt(process.env.RELOAD_PORT || '9090', 10),
proxyBindIp: process.env.PROXY_BIND_IP || '0.0.0.0',
appName: 'VPN-Proxy-Control by Dokril',
webDir: WEB_DIR,
baseDir: BASE_DIR,
dataDir: DATA_DIR,
configFile: path.join(DATA_DIR, 'client.json'),
hwidFile: path.join(DATA_DIR, 'hwid'),
subscriptionFile: path.join(DATA_DIR, 'subscription.json'),
fallbackFile: path.join(DATA_DIR, 'fallback.json'),
proxyEnabledFile: path.join(DATA_DIR, 'proxy_enabled.json'),
startTimeFile: path.join(DATA_DIR, 'start_time.json'),
defaultFallback: {
enabled: false,
host: '192.168.50.111',
port: 8080,
},
};

23
web/api/src/main.ts Normal file
View File

@@ -0,0 +1,23 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import * as path from 'path';
import { AppModule } from './app.module';
import { config } from './config/config';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Serve static files from web/static/
app.useStaticAssets(path.join(config.webDir, 'static'), {
prefix: '/static/',
});
app.enableCors();
await app.listen(config.port);
console.log(`[WebUI] Server started on port ${config.port}`);
console.log(
`[WebUI] Open http://localhost:${config.port} in your browser`,
);
}
bootstrap();

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { NetworkService } from './network.service';
@Module({
providers: [NetworkService],
exports: [NetworkService],
})
export class NetworkModule {}

View File

@@ -0,0 +1,294 @@
import { Injectable } from '@nestjs/common';
import * as net from 'net';
import * as http from 'http';
import * as tls from 'tls';
import { config } from '../config/config';
@Injectable()
export class NetworkService {
measureTcpLatency(
host: string,
port: number,
timeout = 2000,
): Promise<number> {
return new Promise((resolve) => {
const start = Date.now();
const socket = new net.Socket();
socket.setTimeout(timeout);
socket.on('connect', () => {
const latency = Date.now() - start;
socket.destroy();
resolve(latency);
});
socket.on('error', () => {
socket.destroy();
resolve(-1);
});
socket.on('timeout', () => {
socket.destroy();
resolve(-1);
});
socket.connect(port, host);
});
}
async measureProxyPerformance(
enableSpeedTest = false,
): Promise<Record<string, any>> {
const result: Record<string, any> = {};
// 1. Measure Latency
try {
const start = Date.now();
await this.httpViaProxy('http://www.gstatic.com/generate_204', {
headers: { 'User-Agent': 'singbox-test' },
timeout: 5000,
});
result.latency = `${Date.now() - start}ms`;
} catch {
result.latency = 'Error';
}
// 2. Get Public IP (IPv4)
try {
const ipRes = await this.httpViaProxy('http://v4.ident.me', {
headers: { 'User-Agent': 'curl/7.68.0' },
timeout: 5000,
});
result.ip = ipRes.body.trim();
} catch {
try {
const ipRes = await this.httpViaProxy('http://api.ipify.org', {
headers: { 'User-Agent': 'curl/7.68.0' },
timeout: 5000,
});
result.ip = ipRes.body.trim();
} catch {
result.ip = 'Unknown';
}
}
// 3. Speed test
if (enableSpeedTest) {
const testFiles = [
{ url: 'https://speedtest.selectel.ru/100MB', sizeMb: 100 },
{ url: 'https://speedtest.selectel.ru/1GB', sizeMb: 1000 },
];
let speedMbps = 0;
for (const testFile of testFiles) {
try {
console.log(`[WebUI] Testing speed with: ${testFile.url}`);
const start = Date.now();
const maxBytes = 25 * 1024 * 1024;
const minDurationMs = 2000;
const downloaded = await this.downloadViaProxy(
testFile.url,
maxBytes,
minDurationMs,
);
const duration = (Date.now() - start) / 1000;
if (duration > 0.1 && downloaded > 0) {
speedMbps =
Math.round(
((downloaded * 8) / (1000 * 1000) / duration) * 10,
) / 10;
console.log(
`[WebUI] Speed test: downloaded ${(downloaded / (1024 * 1024)).toFixed(1)}MB in ${duration.toFixed(1)}s = ${speedMbps} Mbps`,
);
break;
}
} catch (e) {
console.log(
`[WebUI] Speed test failed for ${testFile.url}: ${e}`,
);
continue;
}
}
result.speed = `${speedMbps} Mbps`;
}
return result;
}
/** Send HTTP request through local HTTP proxy (for http:// URLs) */
private httpViaProxy(
targetUrl: string,
options: {
headers?: Record<string, string>;
timeout?: number;
} = {},
): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(targetUrl);
const req = http.request(
{
hostname: '127.0.0.1',
port: config.proxyPort,
path: targetUrl,
method: 'GET',
headers: {
Host: parsed.host,
...(options.headers || {}),
},
timeout: options.timeout || 5000,
},
(res) => {
let body = '';
res.on('data', (chunk: Buffer) => (body += chunk.toString()));
res.on('end', () =>
resolve({ statusCode: res.statusCode, body }),
);
},
);
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Timeout'));
});
req.end();
});
}
/** Download through proxy via CONNECT tunnel (for https:// URLs) */
private downloadViaProxy(
targetUrl: string,
maxBytes: number,
minDurationMs: number,
): Promise<number> {
return new Promise((resolve, reject) => {
const parsed = new URL(targetUrl);
const isHttps = parsed.protocol === 'https:';
if (!isHttps) {
// HTTP download through proxy
const req = http.request(
{
hostname: '127.0.0.1',
port: config.proxyPort,
path: targetUrl,
method: 'GET',
headers: { Host: parsed.host },
timeout: 30000,
},
(res) => {
let downloaded = 0;
const start = Date.now();
res.on('data', (chunk: Buffer) => {
downloaded += chunk.length;
const elapsed = Date.now() - start;
if (
downloaded >= maxBytes ||
(elapsed >= minDurationMs && downloaded >= 2 * 1024 * 1024)
) {
res.destroy();
resolve(downloaded);
}
});
res.on('end', () => resolve(downloaded));
res.on('error', reject);
},
);
req.on('error', reject);
req.end();
return;
}
// HTTPS: use CONNECT tunnel through proxy
const proxyReq = http.request({
hostname: '127.0.0.1',
port: config.proxyPort,
method: 'CONNECT',
path: `${parsed.hostname}:${parsed.port || 443}`,
});
let resolved = false;
const finish = (bytes: number) => {
if (!resolved) {
resolved = true;
resolve(bytes);
}
};
const fail = (err: Error) => {
if (!resolved) {
resolved = true;
reject(err);
}
};
proxyReq.on('connect', (proxyRes, socket) => {
if (proxyRes.statusCode !== 200) {
socket.destroy();
fail(new Error(`CONNECT failed: ${proxyRes.statusCode}`));
return;
}
const tlsSocket = tls.connect(
{ socket, servername: parsed.hostname },
() => {
// Send raw HTTP request over TLS tunnel
tlsSocket.write(
`GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\n` +
`Host: ${parsed.hostname}\r\n` +
`User-Agent: singbox-speedtest\r\n` +
`Connection: close\r\n\r\n`,
);
let downloaded = 0;
let headersParsed = false;
let headerBuffer = '';
const start = Date.now();
tlsSocket.on('data', (chunk: Buffer) => {
if (!headersParsed) {
headerBuffer += chunk.toString('binary');
const headerEnd = headerBuffer.indexOf('\r\n\r\n');
if (headerEnd !== -1) {
headersParsed = true;
const bodyStart = headerEnd + 4;
downloaded +=
Buffer.byteLength(headerBuffer.slice(bodyStart), 'binary');
}
} else {
downloaded += chunk.length;
}
const elapsed = Date.now() - start;
if (
downloaded >= maxBytes ||
(elapsed >= minDurationMs && downloaded >= 2 * 1024 * 1024)
) {
tlsSocket.destroy();
finish(downloaded);
}
});
tlsSocket.on('end', () => finish(downloaded));
tlsSocket.on('error', fail);
},
);
tlsSocket.on('error', fail);
});
proxyReq.on('error', fail);
proxyReq.setTimeout(30000, () => {
proxyReq.destroy();
fail(new Error('CONNECT timeout'));
});
proxyReq.end();
});
}
}

View File

@@ -0,0 +1,315 @@
import {
Controller,
Get,
Post,
Body,
Query,
Res,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { config } from '../config/config';
import { StorageService } from '../storage/storage.service';
import { VlessService } from '../vless/vless.service';
import { NetworkService } from '../network/network.service';
import { ProxyService } from './proxy.service';
@Controller()
export class ProxyController {
constructor(
private readonly storage: StorageService,
private readonly vless: VlessService,
private readonly network: NetworkService,
private readonly proxyService: ProxyService,
) {}
// --- Static / Index ---
@Get('/')
serveIndex(@Res() res: Response) {
const indexPath = path.join(config.webDir, 'index.html');
if (!fs.existsSync(indexPath)) {
return res.status(404).send('index.html not found');
}
try {
let content = fs.readFileSync(indexPath, 'utf-8');
const normalizedWebDir = path.resolve(config.webDir);
content = content.replace(
/<!-- include "([^"]+)" -->/g,
(_, includePath: string) => {
const fullPath = path.resolve(normalizedWebDir, includePath);
// Path traversal protection
if (
!fullPath.startsWith(normalizedWebDir + path.sep) &&
fullPath !== normalizedWebDir
) {
return `<!-- Include failed: ${includePath} -->`;
}
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
return `<!-- Include failed: ${includePath} -->`;
}
return fs.readFileSync(fullPath, 'utf-8');
},
);
res.type('html').send(content);
} catch (e) {
res.status(500).send(`Error serving index: ${e}`);
}
}
// --- Status ---
@Get('/status')
getStatus() {
const configExists = this.storage.configFileExists();
let currentTag: string | null = null;
let currentServer: string | null = null;
const proxyEnabled = this.storage.loadProxyEnabled();
if (configExists) {
try {
const cfg = this.storage.readConfigFile();
for (const outbound of cfg.outbounds || []) {
if (outbound.type === 'vless') {
currentTag = outbound.tag || 'unknown';
currentServer = outbound.server || 'unknown';
break;
}
}
} catch {}
}
return {
active: configExists && proxyEnabled,
tag: currentTag,
server: currentServer,
proxyPort: config.proxyPort,
proxyEnabled,
startTime:
configExists && proxyEnabled ? this.storage.loadStartTime() : 0,
};
}
// --- Subscription ---
@Get('/subscription')
getSubscription() {
const sub = this.storage.loadSubscription();
if (sub) {
return {
saved: true,
url: sub.url,
selectedServer: sub.selectedServer,
userInfo: sub.userInfo,
};
}
return { saved: false };
}
// --- Connection Test ---
@Get('/test-connection')
async testConnection(@Query('speed') speed?: string) {
const enableSpeed = speed?.toLowerCase() === 'true';
return this.network.measureProxyPerformance(enableSpeed);
}
// --- Fallback Config ---
@Get('/fallback-config')
getFallbackConfig() {
const fallback = this.storage.loadFallbackConfig();
return {
enabled: fallback.enabled ?? false,
host: fallback.host ?? '192.168.50.111',
port: fallback.port ?? 8080,
};
}
@Post('/fallback-config')
saveFallbackConfig(@Body() body: any) {
const enabled = body.enabled ?? false;
const host = (body.host || '').trim();
const port = parseInt(body.port, 10) || 8080;
if (enabled && !host) {
throw new HttpException(
{ success: false, error: 'Host is required' },
HttpStatus.BAD_REQUEST,
);
}
this.storage.saveFallbackConfig(enabled, host, port);
const regenerated = this.proxyService.regenerateCurrentConfig();
return {
success: true,
message: 'Fallback config saved',
regenerated,
};
}
// --- Active Proxy ---
@Get('/active-proxy')
async getActiveProxy() {
return this.proxyService.getActiveProxy();
}
// --- Proxy Enabled ---
@Get('/proxy-enabled')
getProxyEnabled() {
return { enabled: this.storage.loadProxyEnabled() };
}
@Post('/proxy-enabled')
setProxyEnabled(@Body() body: any) {
const enabled = body.enabled ?? true;
this.storage.saveProxyEnabled(enabled);
let regenerated = false;
if (enabled) {
regenerated = this.proxyService.regenerateCurrentConfig();
if (regenerated) {
this.storage.saveStartTime(Date.now() / 1000);
}
} else {
regenerated = this.proxyService.applyDirectConfig();
this.storage.saveStartTime(0);
}
return { success: true, enabled, regenerated };
}
// --- Apply VLESS URL ---
@Post('/apply')
applyConfig(@Body() body: any) {
const url = (body.url || '').trim();
if (!url) {
throw new HttpException(
{ success: false, error: 'URL не указан' },
HttpStatus.BAD_REQUEST,
);
}
if (!url.startsWith('vless://')) {
throw new HttpException(
{
success: false,
error: 'Неверный формат. Поддерживаются только vless:// ссылки',
},
HttpStatus.BAD_REQUEST,
);
}
let vlessParams;
try {
vlessParams = this.vless.parseVlessUrl(url);
} catch (e: any) {
throw new HttpException(
{
success: false,
error: `Ошибка парсинга URL: ${e.message}`,
},
HttpStatus.BAD_REQUEST,
);
}
const cfg = this.vless.generateVlessConfig(vlessParams);
this.storage.writeConfigFile(cfg);
this.proxyService.triggerReload();
this.storage.saveStartTime(Date.now() / 1000);
return {
success: true,
message: `Конфигурация '${vlessParams.tag}' успешно применена!`,
};
}
// --- Fetch Subscription ---
@Post('/fetch-subscription')
async fetchSubscription(@Body() body: any) {
const url = (body.url || '').trim();
if (!url) {
throw new HttpException(
{ success: false, error: 'URL подписки не указан' },
HttpStatus.BAD_REQUEST,
);
}
const result = await this.proxyService.fetchSubscriptionFromUrl(url);
if (!result.success) {
throw new HttpException(result, HttpStatus.BAD_REQUEST);
}
return result;
}
// --- Apply Subscription ---
@Post('/apply-subscription')
applySubscription(@Body() body: any) {
const subConfig = body.config;
const selectedTag = body.selectedServer;
const subUrl = body.subUrl;
const userInfo = body.userInfo;
if (!subConfig) {
throw new HttpException(
{ success: false, error: 'Конфигурация не указана' },
HttpStatus.BAD_REQUEST,
);
}
if (!selectedTag) {
throw new HttpException(
{ success: false, error: 'Сервер не выбран' },
HttpStatus.BAD_REQUEST,
);
}
const result = this.proxyService.applySubscriptionConfig(
subConfig,
selectedTag,
subUrl,
userInfo,
);
if (!result.success) {
throw new HttpException(result, HttpStatus.BAD_REQUEST);
}
return result;
}
// --- Ping Target ---
@Post('/ping-target')
async pingTarget(@Body() body: any) {
const server = body.server;
const port = parseInt(body.port, 10) || 443;
if (!server) {
throw new HttpException(
{ error: 'No server specified' },
HttpStatus.BAD_REQUEST,
);
}
const latency = await this.network.measureTcpLatency(server, port);
return { latency };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { StorageModule } from '../storage/storage.module';
import { VlessModule } from '../vless/vless.module';
import { NetworkModule } from '../network/network.module';
import { ProxyController } from './proxy.controller';
import { ProxyService } from './proxy.service';
@Module({
imports: [StorageModule, VlessModule, NetworkModule],
controllers: [ProxyController],
providers: [ProxyService],
})
export class ProxyModule {}

View File

@@ -0,0 +1,507 @@
import { Injectable } from '@nestjs/common';
import * as http from 'http';
import * as https from 'https';
import { config } from '../config/config';
import { StorageService } from '../storage/storage.service';
import { VlessService } from '../vless/vless.service';
import { NetworkService } from '../network/network.service';
@Injectable()
export class ProxyService {
constructor(
private readonly storage: StorageService,
private readonly vless: VlessService,
private readonly network: NetworkService,
) {}
triggerReload(): void {
try {
const req = http.get(
`http://127.0.0.1:${config.reloadPort}/reload`,
{ timeout: 3000 },
() => {},
);
req.on('error', () => {});
} catch {}
}
/** Regenerate current config with updated fallback settings */
regenerateCurrentConfig(): boolean {
if (!this.storage.configFileExists()) return false;
try {
const cfg = this.storage.readConfigFile();
const outbounds: any[] = cfg.outbounds || [];
let vpnOutbound: any = null;
const utilityOutbounds: any[] = [];
for (const outbound of outbounds) {
if (
['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2'].includes(
outbound.type,
)
) {
vpnOutbound = outbound;
} else if (['direct', 'block', 'dns'].includes(outbound.type)) {
utilityOutbounds.push(outbound);
}
}
if (!vpnOutbound) return false;
const selectedTag = vpnOutbound.tag;
const fallback = this.storage.loadFallbackConfig();
const fallbackEnabled = fallback.enabled;
const fallbackHost = fallback.host;
const fallbackPort = fallback.port;
const finalOutbounds: any[] = [];
let finalTag = selectedTag;
if (fallbackEnabled && fallbackHost) {
finalOutbounds.push({
type: 'urltest',
tag: 'auto-select',
outbounds: ['fallback-proxy', selectedTag],
url: 'http://www.gstatic.com/generate_204',
interval: '30s',
tolerance: 9999,
});
finalOutbounds.push({
type: 'http',
tag: 'fallback-proxy',
server: fallbackHost,
server_port: fallbackPort,
});
finalTag = 'auto-select';
}
finalOutbounds.push(vpnOutbound);
finalOutbounds.push(...utilityOutbounds);
cfg.outbounds = finalOutbounds;
cfg.route.final = finalTag;
this.storage.writeConfigFile(cfg);
this.triggerReload();
return true;
} catch (e) {
console.error(`[WebUI] Failed to regenerate config: ${e}`);
return false;
}
}
/** Generate and apply direct config (bypass proxy) */
applyDirectConfig(): boolean {
try {
const directCfg = this.vless.generateDirectConfig();
this.storage.writeConfigFile(directCfg);
this.triggerReload();
return true;
} catch (e) {
console.error(`[WebUI] Failed to generate direct config: ${e}`);
return false;
}
}
/** Get active proxy chain info */
async getActiveProxy(): Promise<Record<string, any>> {
const result: Record<string, any> = {
configured: false,
fallbackEnabled: false,
fallbackHost: null,
vpnTag: null,
vpnServer: null,
activeOutbound: null,
};
if (!this.storage.configFileExists()) return result;
try {
const cfg = this.storage.readConfigFile();
const outbounds: any[] = cfg.outbounds || [];
const routeFinal = cfg.route?.final;
result.configured = true;
for (const outbound of outbounds) {
const outType = outbound.type;
if (outType === 'urltest') {
result.fallbackEnabled = true;
} else if (outType === 'http' && outbound.tag === 'fallback-proxy') {
result.fallbackHost = `${outbound.server}:${outbound.server_port}`;
} else if (
['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2'].includes(
outType,
)
) {
result.vpnTag = outbound.tag;
result.vpnServer = outbound.server;
}
}
result.activeOutbound = routeFinal;
// Check fallback proxy reachability
if (result.fallbackEnabled && result.fallbackHost) {
try {
const [host, portStr] = result.fallbackHost.split(':');
const latency = await this.network.measureTcpLatency(
host,
parseInt(portStr, 10),
1000,
);
result.fallbackReachable = latency > 0;
result.fallbackLatency = latency > 0 ? latency : null;
} catch {
result.fallbackReachable = false;
result.fallbackLatency = null;
}
}
} catch (e) {
result.error = String(e);
}
return result;
}
/** Fetch subscription from URL */
async fetchSubscriptionFromUrl(url: string): Promise<Record<string, any>> {
// Validate URL scheme (SSRF protection)
let parsed: URL;
try {
parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return {
success: false,
error: 'Недопустимый протокол (только http/https)',
};
}
} catch {
return { success: false, error: 'Некорректный URL' };
}
const sysInfo = this.storage.getSystemInfo();
const headers: Record<string, string> = {
'User-Agent': 'singbox',
'x-hwid': this.storage.getHwid(),
'x-device-os': sysInfo.os,
'x-ver-os': sysInfo.version,
'x-device-model': config.appName,
};
let configText: string;
let userInfo: Record<string, number> = {};
try {
const response = await this.fetchUrl(url, headers);
configText = response.body;
// Parse subscription-userinfo header
const userInfoHeader =
response.headers['subscription-userinfo'] || '';
if (userInfoHeader) {
const parts = (
Array.isArray(userInfoHeader)
? userInfoHeader[0]
: userInfoHeader
).split(';');
for (const part of parts) {
if (part.includes('=')) {
const [key, value] = part.trim().split('=', 2);
const num = parseInt(value, 10);
if (!isNaN(num)) {
userInfo[key] = num;
}
}
}
}
} catch (e: any) {
const msg = e.message || String(e);
if (msg.includes('HTTP Error')) {
return { success: false, error: `Ошибка HTTP: ${msg}` };
}
return { success: false, error: `Ошибка подключения: ${msg}` };
}
// Try to parse as JSON first
let parsedConfig: any = null;
try {
parsedConfig = JSON.parse(configText);
} catch {
// Not JSON — try Base64 decode or plain VLESS links
let content = configText.trim();
try {
if (/^[A-Za-z0-9+/=\s]+$/.test(content)) {
const decoded = Buffer.from(content, 'base64').toString('utf-8');
content = decoded;
}
} catch {}
// Parse VLESS links
const lines = content.split('\n');
const vlessLinks = lines
.map((l) => l.trim())
.filter((l) => l.startsWith('vless://'));
if (vlessLinks.length === 0) {
return {
success: false,
error: 'Не найдены VLESS ссылки в ответе',
};
}
const outbounds: any[] = [];
for (const link of vlessLinks) {
try {
const params = this.vless.parseVlessUrl(link);
outbounds.push({
type: 'vless',
tag: params.tag,
server: params.server,
server_port: params.server_port,
uuid: params.uuid,
flow: params.flow,
tls: {
enabled: true,
server_name: params.server_name,
utls: {
enabled: true,
fingerprint: params.fingerprint,
},
reality: {
enabled: true,
public_key: params.public_key,
short_id: params.short_id,
},
},
packet_encoding: 'xudp',
});
} catch (e) {
console.error(`[WebUI] Failed to parse VLESS link: ${e}`);
continue;
}
}
if (outbounds.length === 0) {
return {
success: false,
error: 'Не удалось распарсить VLESS ссылки',
};
}
parsedConfig = { outbounds };
}
// Extract servers
const outbounds: any[] = parsedConfig.outbounds || [];
const servers: any[] = [];
for (const outbound of outbounds) {
if (
['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2'].includes(
outbound.type,
)
) {
servers.push({
tag: outbound.tag || 'unknown',
type: outbound.type,
server: outbound.server || 'unknown',
server_port: outbound.server_port || 443,
});
}
}
if (servers.length === 0) {
return {
success: false,
error: 'Серверы не найдены в подписке',
};
}
return {
success: true,
servers,
config: parsedConfig,
userInfo,
};
}
/** Apply subscription config with selected server */
applySubscriptionConfig(
subConfig: any,
selectedTag: string,
subUrl?: string,
userInfoData?: any,
): { success: boolean; message?: string; error?: string } {
const outbounds: any[] = subConfig.outbounds || [];
const newOutbounds: any[] = [];
let selectedOutbound: any = null;
for (const outbound of outbounds) {
if (outbound.tag === selectedTag) {
selectedOutbound = outbound;
} else if (['direct', 'block', 'dns'].includes(outbound.type)) {
newOutbounds.push(outbound);
}
// Skip selector type
}
if (!selectedOutbound) {
return {
success: false,
error: `Сервер '${selectedTag}' не найден`,
};
}
// Load fallback configuration
const fallback = this.storage.loadFallbackConfig();
const finalOutbounds: any[] = [];
let finalTag = selectedTag;
if (fallback.enabled && fallback.host) {
finalOutbounds.push({
type: 'urltest',
tag: 'auto-select',
outbounds: ['fallback-proxy', selectedTag],
url: 'http://www.gstatic.com/generate_204',
interval: '30s',
tolerance: 9999,
});
finalOutbounds.push({
type: 'http',
tag: 'fallback-proxy',
server: fallback.host,
server_port: fallback.port,
});
finalTag = 'auto-select';
}
// Add selected VPN server
finalOutbounds.push(selectedOutbound);
// Add utility outbounds
finalOutbounds.push(...newOutbounds);
// Build config
subConfig.dns = { independent_cache: true };
delete subConfig.platform;
delete subConfig.experimental;
subConfig.inbounds = [
{
tag: 'mixed-in',
type: 'mixed',
sniff: true,
users: [],
listen: config.proxyBindIp,
listen_port: config.proxyPort,
set_system_proxy: false,
},
];
subConfig.outbounds = finalOutbounds;
subConfig.route = {
final: finalTag,
auto_detect_interface: true,
};
// Write config
this.storage.writeConfigFile(subConfig);
// Save subscription for persistence
if (subUrl) {
this.storage.saveSubscription(subUrl, selectedTag, userInfoData);
}
// Reload sing-box
this.triggerReload();
return {
success: true,
message: `Сервер '${selectedTag}' успешно применён!`,
};
}
/** Fetch a URL directly (not through proxy) with redirect support */
private fetchUrl(
url: string,
headers: Record<string, string>,
maxRedirects = 5,
): Promise<{
body: string;
headers: Record<string, string | string[]>;
}> {
if (maxRedirects <= 0) {
return Promise.reject(new Error('Too many redirects'));
}
return new Promise((resolve, reject) => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
reject(new Error('Invalid URL'));
return;
}
const mod = parsed.protocol === 'https:' ? https : http;
const req = mod.request(
{
hostname: parsed.hostname,
port:
parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'GET',
headers,
timeout: 15000,
},
(res) => {
// Handle redirects
if (
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location
) {
this.fetchUrl(res.headers.location, headers, maxRedirects - 1)
.then(resolve)
.catch(reject);
return;
}
if (res.statusCode >= 400) {
reject(new Error(`HTTP Error: ${res.statusCode}`));
return;
}
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
resolve({
body,
headers: res.headers as Record<string, string | string[]>,
});
});
},
);
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Timeout'));
});
req.end();
});
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View File

@@ -0,0 +1,151 @@
import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as os from 'os';
import { config } from '../config/config';
@Injectable()
export class StorageService {
private ensureDataDir(): void {
fs.mkdirSync(config.dataDir, { recursive: true });
}
// --- Subscription ---
saveSubscription(
url: string,
selectedServer?: string,
userInfo?: any,
): void {
this.ensureDataDir();
const data = { url, selectedServer, userInfo };
fs.writeFileSync(
config.subscriptionFile,
JSON.stringify(data, null, 2),
'utf-8',
);
}
loadSubscription(): any {
try {
if (fs.existsSync(config.subscriptionFile)) {
return JSON.parse(fs.readFileSync(config.subscriptionFile, 'utf-8'));
}
} catch {}
return null;
}
// --- Fallback Config ---
saveFallbackConfig(enabled: boolean, host: string, port: number): void {
this.ensureDataDir();
const data = { enabled, host, port };
fs.writeFileSync(
config.fallbackFile,
JSON.stringify(data, null, 2),
'utf-8',
);
}
loadFallbackConfig(): { enabled: boolean; host: string; port: number } {
try {
if (fs.existsSync(config.fallbackFile)) {
return JSON.parse(fs.readFileSync(config.fallbackFile, 'utf-8'));
}
} catch {}
return { ...config.defaultFallback };
}
// --- Proxy Enabled ---
saveProxyEnabled(enabled: boolean): void {
this.ensureDataDir();
fs.writeFileSync(
config.proxyEnabledFile,
JSON.stringify({ enabled }),
'utf-8',
);
}
loadProxyEnabled(): boolean {
try {
if (fs.existsSync(config.proxyEnabledFile)) {
const data = JSON.parse(
fs.readFileSync(config.proxyEnabledFile, 'utf-8'),
);
return data.enabled ?? true;
}
} catch {}
return true;
}
// --- Start Time ---
saveStartTime(startTime: number): void {
this.ensureDataDir();
fs.writeFileSync(
config.startTimeFile,
JSON.stringify({ startTime }),
'utf-8',
);
}
loadStartTime(): number {
try {
if (fs.existsSync(config.startTimeFile)) {
const data = JSON.parse(
fs.readFileSync(config.startTimeFile, 'utf-8'),
);
return data.startTime ?? 0;
}
} catch {}
return 0;
}
// --- HWID ---
getHwid(): string {
this.ensureDataDir();
try {
if (fs.existsSync(config.hwidFile)) {
return fs.readFileSync(config.hwidFile, 'utf-8').trim();
}
} catch {}
const hwid = crypto.randomBytes(8).toString('hex');
fs.writeFileSync(config.hwidFile, hwid, 'utf-8');
return hwid;
}
// --- System Info ---
getSystemInfo(): { os: string; version: string } {
return {
os: os.platform(),
version: os.release(),
};
}
// --- Config File ---
readConfigFile(): any {
try {
if (fs.existsSync(config.configFile)) {
return JSON.parse(fs.readFileSync(config.configFile, 'utf-8'));
}
} catch {}
return null;
}
writeConfigFile(data: any): void {
this.ensureDataDir();
fs.writeFileSync(
config.configFile,
JSON.stringify(data, null, 2),
'utf-8',
);
}
configFileExists(): boolean {
return fs.existsSync(config.configFile);
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { VlessService } from './vless.service';
@Module({
providers: [VlessService],
exports: [VlessService],
})
export class VlessModule {}

View File

@@ -0,0 +1,158 @@
import { Injectable } from '@nestjs/common';
import { config } from '../config/config';
export interface VlessParams {
uuid: string;
server: string;
server_port: number;
tag: string;
public_key: string;
short_id: string;
server_name: string;
fingerprint: string;
flow: string;
}
@Injectable()
export class VlessService {
parseVlessUrl(url: string): VlessParams {
if (!url.startsWith('vless://')) {
throw new Error('URL must start with vless://');
}
let urlNoScheme = url.slice(8);
// Split by fragment (#tag)
let tag = 'reality';
const hashIndex = urlNoScheme.indexOf('#');
if (hashIndex !== -1) {
tag = decodeURIComponent(urlNoScheme.slice(hashIndex + 1));
urlNoScheme = urlNoScheme.slice(0, hashIndex);
}
// Split by query (?)
const qIndex = urlNoScheme.indexOf('?');
if (qIndex === -1) {
throw new Error('Missing query parameters');
}
const uuidHostPort = urlNoScheme.slice(0, qIndex);
const queryString = urlNoScheme.slice(qIndex + 1);
// Parse UUID@host:port
const atIndex = uuidHostPort.indexOf('@');
if (atIndex === -1) {
throw new Error('Missing @ separator');
}
const uuid = uuidHostPort.slice(0, atIndex);
const hostPort = uuidHostPort.slice(atIndex + 1);
const lastColon = hostPort.lastIndexOf(':');
if (lastColon === -1) {
throw new Error('Missing port');
}
const host = hostPort.slice(0, lastColon);
const port = parseInt(hostPort.slice(lastColon + 1), 10);
// Parse query parameters
const params: Record<string, string> = {};
for (const param of queryString.split('&')) {
const eqIndex = param.indexOf('=');
if (eqIndex !== -1) {
params[param.slice(0, eqIndex)] = decodeURIComponent(
param.slice(eqIndex + 1),
);
}
}
const pbk = params.pbk || '';
const sid = params.sid || '';
const sni = params.sni || host;
const fp = params.fp || 'chrome';
const flow = params.flow || '';
if (!pbk || !sid) {
throw new Error('Missing required parameters: pbk or sid');
}
return {
uuid,
server: host,
server_port: port,
tag,
public_key: pbk,
short_id: sid,
server_name: sni,
fingerprint: fp,
flow,
};
}
generateVlessConfig(vlessParams: VlessParams): any {
return {
dns: { independent_cache: true },
log: { level: 'debug', disabled: true, timestamp: true },
route: {
final: vlessParams.tag,
auto_detect_interface: true,
},
inbounds: [
{
tag: 'mixed-in',
type: 'mixed',
sniff: true,
users: [],
listen: '0.0.0.0',
listen_port: config.proxyPort,
set_system_proxy: false,
},
],
outbounds: [
{
type: 'vless',
tag: vlessParams.tag,
server: vlessParams.server,
server_port: vlessParams.server_port,
flow: vlessParams.flow,
tls: {
enabled: true,
server_name: vlessParams.server_name,
reality: {
enabled: true,
public_key: vlessParams.public_key,
short_id: vlessParams.short_id,
},
utls: {
enabled: true,
fingerprint: vlessParams.fingerprint,
},
},
uuid: vlessParams.uuid,
},
{ tag: 'direct', type: 'direct' },
],
};
}
generateDirectConfig(): any {
return {
dns: { independent_cache: true },
log: { level: 'debug', disabled: true, timestamp: true },
route: { final: 'direct', auto_detect_interface: true },
inbounds: [
{
tag: 'mixed-in',
type: 'mixed',
sniff: true,
users: [],
listen: '0.0.0.0',
listen_port: config.proxyPort,
set_system_proxy: false,
},
],
outbounds: [{ tag: 'direct', type: 'direct' }],
};
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

22
web/api/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"types": ["node"],
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}