508 lines
14 KiB
TypeScript
508 lines
14 KiB
TypeScript
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();
|
||
});
|
||
}
|
||
}
|