Rebuild vpn proxy around gateway mode
This commit is contained in:
21
src/server/config.js
Normal file
21
src/server/config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import path from 'node:path';
|
||||
|
||||
const dataDir = process.env.DATA_DIR || path.resolve('.vpn-proxy');
|
||||
|
||||
export const settings = {
|
||||
port: Number(process.env.PORT || 3456),
|
||||
proxyPort: Number(process.env.PROXY_PORT || 8080),
|
||||
tproxyPort: Number(process.env.TPROXY_PORT || 7895),
|
||||
bindIp: process.env.PROXY_BIND_IP || '0.0.0.0',
|
||||
dataDir,
|
||||
distDir: process.env.DIST_DIR || '/app/dist',
|
||||
configPath: process.env.SING_BOX_CONFIG || '/etc/sing-box/config.json',
|
||||
cachePath: process.env.SING_BOX_CACHE || '/var/lib/sing-box/cache.db',
|
||||
statePath: path.join(dataDir, 'state.json'),
|
||||
customRulesPath: path.join(dataDir, 'custom-rules.json'),
|
||||
subscriptionCachePath: path.join(dataDir, 'subscription-cache.json'),
|
||||
hwidPath: path.join(dataDir, 'hwid'),
|
||||
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || 'true') !== 'false',
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
appName: 'VPN Proxy Gateway',
|
||||
};
|
||||
274
src/server/index.js
Normal file
274
src/server/index.js
Normal file
@@ -0,0 +1,274 @@
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { settings } from './config.js';
|
||||
import { fetchSubscription } from './subscription.js';
|
||||
import { buildGatewayConfig, writeSingboxConfig } from './singbox.js';
|
||||
|
||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||
|
||||
let singboxProcess = null;
|
||||
let singboxStartedAt = null;
|
||||
|
||||
function readJson(filePath, fallback) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return fallback;
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
const body = JSON.stringify(payload, null, 2);
|
||||
res.writeHead(statusCode, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
if (!chunks.length) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch {
|
||||
reject(new Error('Invalid JSON body'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function checkSingboxConfig() {
|
||||
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error((result.stderr || result.stdout || 'sing-box check failed').trim());
|
||||
}
|
||||
}
|
||||
|
||||
function stopSingbox() {
|
||||
return new Promise((resolve) => {
|
||||
if (!singboxProcess) return resolve();
|
||||
|
||||
const current = singboxProcess;
|
||||
singboxProcess = null;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
current.kill('SIGKILL');
|
||||
resolve();
|
||||
}, 4000);
|
||||
|
||||
current.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
current.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
async function startSingbox() {
|
||||
if (!fs.existsSync(settings.configPath)) return false;
|
||||
|
||||
checkSingboxConfig();
|
||||
await stopSingbox();
|
||||
|
||||
singboxProcess = spawn('sing-box', ['run', '-c', settings.configPath], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
singboxStartedAt = new Date().toISOString();
|
||||
|
||||
singboxProcess.once('exit', (code, signal) => {
|
||||
console.log(`[control] sing-box exited: code=${code} signal=${signal}`);
|
||||
if (singboxProcess?.exitCode === code) singboxProcess = null;
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function publicState() {
|
||||
const state = readJson(settings.statePath, {});
|
||||
const customRules = readJson(settings.customRulesPath, []);
|
||||
return {
|
||||
mode: 'gateway',
|
||||
port: settings.port,
|
||||
proxyPort: settings.proxyPort,
|
||||
tproxyPort: settings.tproxyPort,
|
||||
routingRuDirect: settings.routingRuDirect,
|
||||
configExists: fs.existsSync(settings.configPath),
|
||||
singboxRunning: Boolean(singboxProcess),
|
||||
singboxStartedAt,
|
||||
customRules,
|
||||
...state,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item || '').trim()).filter(Boolean);
|
||||
}
|
||||
return String(value || '')
|
||||
.split(/\r?\n|,/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeCustomRules(input) {
|
||||
const rules = Array.isArray(input) ? input : [];
|
||||
return rules.map((rule, index) => ({
|
||||
id: String(rule.id || `rule-${Date.now()}-${index}`),
|
||||
name: String(rule.name || `Rule ${index + 1}`).trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
outbound: ['direct', 'vpn', 'block'].includes(rule.outbound) ? rule.outbound : 'direct',
|
||||
domains: normalizeList(rule.domains),
|
||||
domainSuffixes: normalizeList(rule.domainSuffixes),
|
||||
domainKeywords: normalizeList(rule.domainKeywords),
|
||||
ipCidrs: normalizeList(rule.ipCidrs),
|
||||
ports: normalizeList(rule.ports),
|
||||
networks: normalizeList(rule.networks).filter((network) => ['tcp', 'udp'].includes(network)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleApi(req, res) {
|
||||
if (req.method === 'GET' && req.url === '/api/state') {
|
||||
return sendJson(res, 200, publicState());
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url === '/api/rules') {
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
rules: readJson(settings.customRulesPath, []),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'PUT' && req.url === '/api/rules') {
|
||||
const body = await readBody(req);
|
||||
const rules = normalizeCustomRules(body.rules);
|
||||
writeJson(settings.customRulesPath, rules);
|
||||
return sendJson(res, 200, { success: true, rules });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
|
||||
const body = await readBody(req);
|
||||
const url = String(body.url || '').trim();
|
||||
if (!url) return sendJson(res, 400, { success: false, error: 'Subscription URL is required' });
|
||||
|
||||
const parsed = await fetchSubscription(url);
|
||||
writeJson(settings.subscriptionCachePath, { url, ...parsed });
|
||||
|
||||
const prevState = readJson(settings.statePath, {});
|
||||
writeJson(settings.statePath, {
|
||||
...prevState,
|
||||
subscriptionUrl: url,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
});
|
||||
|
||||
return sendJson(res, 200, { success: true, ...parsed });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/apply') {
|
||||
const body = await readBody(req);
|
||||
const selectedTag = String(body.selectedTag || '').trim();
|
||||
if (!selectedTag) return sendJson(res, 400, { success: false, error: 'selectedTag is required' });
|
||||
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (!cached?.config) {
|
||||
return sendJson(res, 400, { success: false, error: 'Fetch subscription before applying a server' });
|
||||
}
|
||||
|
||||
const customRules = readJson(settings.customRulesPath, []);
|
||||
const generated = buildGatewayConfig({ ...cached.config, customRules }, selectedTag);
|
||||
writeSingboxConfig(generated);
|
||||
await startSingbox();
|
||||
|
||||
const prevState = readJson(settings.statePath, {});
|
||||
writeJson(settings.statePath, {
|
||||
...prevState,
|
||||
selectedTag,
|
||||
appliedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
selectedTag,
|
||||
configPath: settings.configPath,
|
||||
singboxRunning: Boolean(singboxProcess),
|
||||
});
|
||||
}
|
||||
|
||||
return sendJson(res, 404, { success: false, error: 'Not found' });
|
||||
}
|
||||
|
||||
const mime = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
};
|
||||
|
||||
function serveStatic(req, res) {
|
||||
const requestPath = new URL(req.url, `http://localhost:${settings.port}`).pathname;
|
||||
const cleanPath = requestPath === '/' ? '/index.html' : requestPath;
|
||||
const filePath = path.resolve(settings.distDir, `.${cleanPath}`);
|
||||
const distRoot = path.resolve(settings.distDir);
|
||||
|
||||
if (!filePath.startsWith(distRoot)) {
|
||||
res.writeHead(403);
|
||||
return res.end('Forbidden');
|
||||
}
|
||||
|
||||
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||
? filePath
|
||||
: path.join(settings.distDir, 'index.html');
|
||||
|
||||
const ext = path.extname(finalPath);
|
||||
res.writeHead(200, { 'content-type': mime[ext] || 'application/octet-stream' });
|
||||
fs.createReadStream(finalPath).pipe(res);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.url?.startsWith('/api/')) {
|
||||
return await handleApi(req, res);
|
||||
}
|
||||
return serveStatic(req, res);
|
||||
} catch (error) {
|
||||
console.error('[control] request failed', error);
|
||||
return sendJson(res, 500, { success: false, error: error.message || String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await stopSingbox();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
await stopSingbox();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
await startSingbox().catch((error) => {
|
||||
console.warn(`[control] sing-box was not started: ${error.message}`);
|
||||
});
|
||||
|
||||
server.listen(settings.port, '0.0.0.0', () => {
|
||||
console.log(`[control] gateway UI listening on :${settings.port}`);
|
||||
});
|
||||
175
src/server/singbox.js
Normal file
175
src/server/singbox.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { settings } from './config.js';
|
||||
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
const CUSTOM_OUTBOUNDS = new Set(['direct', 'vpn', 'block']);
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function findOutbound(subscriptionConfig, selectedTag) {
|
||||
const outbounds = Array.isArray(subscriptionConfig?.outbounds) ? subscriptionConfig.outbounds : [];
|
||||
return outbounds.find((outbound) => outbound.tag === selectedTag && PROXY_TYPES.has(outbound.type));
|
||||
}
|
||||
|
||||
function ruleSets() {
|
||||
if (!settings.routingRuDirect) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'remote',
|
||||
tag: 'geoip-ru',
|
||||
format: 'binary',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs',
|
||||
download_detour: 'direct',
|
||||
},
|
||||
{
|
||||
type: 'remote',
|
||||
tag: 'geosite-category-ru',
|
||||
format: 'binary',
|
||||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs',
|
||||
download_detour: 'direct',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function uniqueClean(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(Array.isArray(values) ? values : [])
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parsePorts(values) {
|
||||
return uniqueClean(values)
|
||||
.map((value) => Number.parseInt(value, 10))
|
||||
.filter((value) => Number.isInteger(value) && value > 0 && value <= 65535);
|
||||
}
|
||||
|
||||
function toSingboxRule(customRule, vpnTag) {
|
||||
if (!customRule?.enabled) return null;
|
||||
if (!CUSTOM_OUTBOUNDS.has(customRule.outbound)) return null;
|
||||
|
||||
const rule = {};
|
||||
const domains = uniqueClean(customRule.domains);
|
||||
const domainSuffixes = uniqueClean(customRule.domainSuffixes);
|
||||
const domainKeywords = uniqueClean(customRule.domainKeywords);
|
||||
const ipCidrs = uniqueClean(customRule.ipCidrs);
|
||||
const ports = parsePorts(customRule.ports);
|
||||
const networks = uniqueClean(customRule.networks).filter((network) => ['tcp', 'udp'].includes(network));
|
||||
|
||||
if (domains.length) rule.domain = domains;
|
||||
if (domainSuffixes.length) rule.domain_suffix = domainSuffixes;
|
||||
if (domainKeywords.length) rule.domain_keyword = domainKeywords;
|
||||
if (ipCidrs.length) rule.ip_cidr = ipCidrs;
|
||||
if (ports.length) rule.port = ports;
|
||||
if (networks.length) rule.network = networks;
|
||||
|
||||
if (
|
||||
!rule.domain &&
|
||||
!rule.domain_suffix &&
|
||||
!rule.domain_keyword &&
|
||||
!rule.ip_cidr &&
|
||||
!rule.port &&
|
||||
!rule.network
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
rule.outbound = customRule.outbound === 'vpn' ? vpnTag : customRule.outbound;
|
||||
return rule;
|
||||
}
|
||||
|
||||
function customRouteRules(customRules, vpnTag) {
|
||||
return (Array.isArray(customRules) ? customRules : [])
|
||||
.map((rule) => toSingboxRule(rule, vpnTag))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function routeRules(customRules, vpnTag) {
|
||||
const rules = [
|
||||
{
|
||||
ip_is_private: true,
|
||||
outbound: 'direct',
|
||||
},
|
||||
];
|
||||
|
||||
rules.push(...customRouteRules(customRules, vpnTag));
|
||||
|
||||
if (settings.routingRuDirect) {
|
||||
rules.push({
|
||||
rule_set: ['geoip-ru', 'geosite-category-ru'],
|
||||
outbound: 'direct',
|
||||
});
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
||||
const selectedOutbound = findOutbound(subscriptionConfig, selectedTag);
|
||||
if (!selectedOutbound) {
|
||||
throw new Error(`Selected outbound not found: ${selectedTag}`);
|
||||
}
|
||||
|
||||
const vpnOutbound = clone(selectedOutbound);
|
||||
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
|
||||
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
|
||||
vpnOutbound.packet_encoding = 'xudp';
|
||||
}
|
||||
|
||||
return {
|
||||
log: {
|
||||
level: settings.logLevel,
|
||||
timestamp: true,
|
||||
},
|
||||
experimental: {
|
||||
cache_file: {
|
||||
enabled: true,
|
||||
path: settings.cachePath,
|
||||
},
|
||||
},
|
||||
dns: {
|
||||
independent_cache: true,
|
||||
},
|
||||
inbounds: [
|
||||
{
|
||||
type: 'tproxy',
|
||||
tag: 'tproxy-in',
|
||||
listen: '::',
|
||||
listen_port: settings.tproxyPort,
|
||||
sniff: true,
|
||||
sniff_override_destination: true,
|
||||
},
|
||||
{
|
||||
type: 'mixed',
|
||||
tag: 'mixed-in',
|
||||
listen: settings.bindIp,
|
||||
listen_port: settings.proxyPort,
|
||||
sniff: true,
|
||||
set_system_proxy: false,
|
||||
},
|
||||
],
|
||||
outbounds: [
|
||||
vpnOutbound,
|
||||
{ type: 'direct', tag: 'direct' },
|
||||
{ type: 'block', tag: 'block' },
|
||||
],
|
||||
route: {
|
||||
rule_set: ruleSets(),
|
||||
rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
|
||||
final: vpnOutbound.tag,
|
||||
auto_detect_interface: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function writeSingboxConfig(config) {
|
||||
fs.mkdirSync(path.dirname(settings.configPath), { recursive: true });
|
||||
fs.writeFileSync(settings.configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
}
|
||||
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