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

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();
});
}
}