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> { const result: Record = { 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> { // 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 = { '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 = {}; 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, maxRedirects = 5, ): Promise<{ body: string; headers: Record; }> { 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, }); }); }, ); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); req.end(); }); } }