feat: add network module and service for TCP latency measurement and proxy performance
This commit is contained in:
507
web/api/src/proxy/proxy.service.ts
Normal file
507
web/api/src/proxy/proxy.service.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user