Files
vpn-proxy/web/api/src/proxy/proxy.service.ts

508 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
}
}