import crypto from 'node:crypto'; import fs from 'node:fs'; import { settings } from './config.js'; const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); export function getHwid() { fs.mkdirSync(settings.dataDir, { recursive: true }); if (fs.existsSync(settings.hwidPath)) { return fs.readFileSync(settings.hwidPath, 'utf8').trim(); } const hwid = crypto.randomBytes(8).toString('hex'); fs.writeFileSync(settings.hwidPath, hwid, 'utf8'); return hwid; } export function subscriptionHeaders() { return { 'user-agent': 'singbox', 'x-hwid': getHwid(), 'x-device-os': process.platform, 'x-ver-os': process.version, 'x-device-model': settings.appName, }; } export function parseUserInfo(headerValue) { const result = {}; if (!headerValue) return result; for (const part of String(headerValue).split(';')) { const [key, value] = part.trim().split('=', 2); if (!key || value === undefined) continue; const parsed = Number.parseInt(value, 10); if (!Number.isNaN(parsed)) result[key] = parsed; } return result; } export function parseVlessUrl(rawUrl) { if (!rawUrl.startsWith('vless://')) { throw new Error('VLESS URL must start with vless://'); } const parsed = new URL(rawUrl); const tag = decodeURIComponent(parsed.hash ? parsed.hash.slice(1) : 'vless-out'); const uuid = decodeURIComponent(parsed.username || ''); const server = parsed.hostname; const serverPort = Number.parseInt(parsed.port || '443', 10); const publicKey = parsed.searchParams.get('pbk') || ''; const shortId = parsed.searchParams.get('sid') || ''; const serverName = parsed.searchParams.get('sni') || server; const fingerprint = parsed.searchParams.get('fp') || 'chrome'; const flow = parsed.searchParams.get('flow') || ''; if (!uuid || !server || !serverPort) { throw new Error('VLESS URL misses uuid, host or port'); } if (!publicKey || !shortId) { throw new Error('VLESS REALITY parameters pbk and sid are required'); } return { type: 'vless', tag, server, server_port: serverPort, uuid, flow, tls: { enabled: true, server_name: serverName, utls: { enabled: true, fingerprint, }, reality: { enabled: true, public_key: publicKey, short_id: shortId, }, }, packet_encoding: 'xudp', }; } function maybeDecodeBase64(content) { const compact = content.trim().replace(/\s+/g, ''); if (!compact || !/^[A-Za-z0-9+/=]+$/.test(compact)) return content; try { const decoded = Buffer.from(compact, 'base64').toString('utf8'); if (decoded.includes('vless://') || decoded.includes('{')) return decoded; } catch {} return content; } export function parseSubscriptionBody(body) { let parsedConfig = null; try { parsedConfig = JSON.parse(body); } catch { const decoded = maybeDecodeBase64(body); const links = decoded .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.startsWith('vless://')); if (!links.length) { throw new Error('Subscription does not contain JSON config or VLESS links'); } parsedConfig = { outbounds: links.map(parseVlessUrl), }; } const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : []; const servers = outbounds .filter((outbound) => PROXY_TYPES.has(outbound.type)) .map((outbound) => ({ tag: outbound.tag || `${outbound.type}-${outbound.server || 'server'}`, type: outbound.type, server: outbound.server || 'unknown', server_port: outbound.server_port || 443, })); if (!servers.length) { throw new Error('No supported proxy outbounds found in subscription'); } return { config: parsedConfig, servers }; } export async function fetchSubscription(url) { let parsedUrl; try { parsedUrl = new URL(url); } catch { throw new Error('Invalid subscription URL'); } if (!['http:', 'https:'].includes(parsedUrl.protocol)) { throw new Error('Subscription URL must use http or https'); } const response = await fetch(parsedUrl, { headers: subscriptionHeaders(), redirect: 'follow', }); if (!response.ok) { throw new Error(`Subscription request failed: HTTP ${response.status}`); } const body = await response.text(); const userInfo = parseUserInfo(response.headers.get('subscription-userinfo')); const parsed = parseSubscriptionBody(body); return { ...parsed, userInfo, fetchedAt: new Date().toISOString(), }; }