Rebuild vpn proxy around gateway mode
This commit is contained in:
169
src/server/subscription.js
Normal file
169
src/server/subscription.js
Normal file
@@ -0,0 +1,169 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user