Rebuild vpn proxy around gateway mode

This commit is contained in:
2026-05-08 16:04:38 +03:00
parent a3816cbedc
commit ef752d66bc
66 changed files with 1884 additions and 14734 deletions

274
src/server/index.js Normal file
View 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}`);
});