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

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

175
src/server/singbox.js Normal file
View 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
View 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(),
};
}

451
src/web/App.jsx Normal file
View File

@@ -0,0 +1,451 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
function formatBytes(value) {
if (!value) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = value;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function maskUrl(value) {
if (!value) return '';
try {
const url = new URL(value);
return `${url.hostname}/...`;
} catch {
return value.length > 48 ? `${value.slice(0, 48)}...` : value;
}
}
function App() {
const [state, setState] = useState(null);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [servers, setServers] = useState([]);
const [customRules, setCustomRules] = useState([]);
const [selectedTag, setSelectedTag] = useState('');
const [busy, setBusy] = useState(false);
const [log, setLog] = useState([]);
const [error, setError] = useState('');
const [rulesSaveStatus, setRulesSaveStatus] = useState('saved');
const rulesDirtyRef = useRef(false);
const rulesSaveTimerRef = useRef(null);
const rulesRevisionRef = useRef(0);
const userTraffic = useMemo(() => {
const info = state?.userInfo;
if (!info) return 'нет данных';
const used = formatBytes((info.upload || 0) + (info.download || 0));
const total = info.total ? formatBytes(info.total) : 'без лимита';
return `${used} / ${total}`;
}, [state]);
function addLog(message) {
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
setLog((items) => [{ time, message }, ...items].slice(0, 8));
}
async function loadState() {
const response = await fetch('/api/state');
const data = await response.json();
setState(data);
setServers(data.servers || []);
if (!rulesDirtyRef.current) {
setCustomRules(data.customRules || []);
}
setSelectedTag(data.selectedTag || '');
if (data.subscriptionUrl && !subscriptionUrl) setSubscriptionUrl(data.subscriptionUrl);
}
useEffect(() => {
loadState().catch(() => {});
const timer = setInterval(() => loadState().catch(() => {}), 5000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
return () => {
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
};
}, []);
async function fetchServers() {
setBusy(true);
setError('');
addLog(`SYNC ${maskUrl(subscriptionUrl)}`);
try {
const response = await fetch('/api/subscription/fetch', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: subscriptionUrl }),
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || 'sync failed');
setServers(data.servers || []);
setSelectedTag(data.servers?.[0]?.tag || '');
addLog(`FOUND ${data.servers.length} servers`);
await loadState();
} catch (err) {
setError(err.message);
addLog(`ERROR ${err.message}`);
} finally {
setBusy(false);
}
}
async function applyServer() {
setBusy(true);
setError('');
addLog(`APPLY ${selectedTag}`);
try {
const response = await fetch('/api/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ selectedTag }),
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || 'apply failed');
addLog(`SING-BOX ${data.singboxRunning ? 'RUNNING' : 'STOPPED'}`);
await loadState();
} catch (err) {
setError(err.message);
addLog(`ERROR ${err.message}`);
} finally {
setBusy(false);
}
}
function emptyRule() {
return {
id: `rule-${Date.now()}`,
name: 'Новый список',
enabled: true,
outbound: 'direct',
domains: [],
domainSuffixes: [],
domainKeywords: [],
ipCidrs: [],
ports: [],
networks: [],
};
}
function listToText(value) {
return Array.isArray(value) ? value.join('\n') : '';
}
function textToList(value) {
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
function updateRule(id, patch) {
setCustomRules((rules) => {
const nextRules = rules.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule));
queueRulesSave(nextRules);
return nextRules;
});
}
function queueRulesSave(nextRules) {
rulesDirtyRef.current = true;
const revision = rulesRevisionRef.current + 1;
rulesRevisionRef.current = revision;
setRulesSaveStatus('pending');
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
rulesSaveTimerRef.current = setTimeout(() => {
saveRules(nextRules, { silent: true, revision });
}, 700);
}
async function saveRules(nextRules = customRules, options = {}) {
const { silent = false, revision = rulesRevisionRef.current + 1 } = options;
if (!silent) setBusy(true);
setError('');
if (!silent) addLog('SAVE ROUTING RULES');
setRulesSaveStatus('saving');
try {
const response = await fetch('/api/rules', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ rules: nextRules }),
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || 'rules save failed');
if (rulesRevisionRef.current === revision) {
rulesDirtyRef.current = false;
setCustomRules(data.rules || []);
setRulesSaveStatus('saved');
addLog(`RULES SAVED ${data.rules.length}`);
await loadState();
} else {
setRulesSaveStatus('pending');
}
} catch (err) {
setError(err.message);
setRulesSaveStatus('error');
addLog(`ERROR ${err.message}`);
} finally {
if (!silent) setBusy(false);
}
}
function saveRulesNow() {
if (rulesSaveTimerRef.current) clearTimeout(rulesSaveTimerRef.current);
rulesDirtyRef.current = true;
const revision = rulesRevisionRef.current + 1;
rulesRevisionRef.current = revision;
saveRules(customRules, { silent: false, revision });
}
function addRule() {
setCustomRules((rules) => {
const nextRules = [emptyRule(), ...rules];
queueRulesSave(nextRules);
return nextRules;
});
}
function removeRule(id) {
setCustomRules((rules) => {
const nextRules = rules.filter((rule) => rule.id !== id);
queueRulesSave(nextRules);
return nextRules;
});
}
return (
<main className="shell">
<section className="hero panel">
<div>
<p className="eyebrow">VPN Proxy / Gateway Mode</p>
<h1>Transparent gateway for the whole network</h1>
<p className="lead">
Вставь subscription URL, выбери outbound, и контейнер сгенерирует gateway-конфиг для sing-box: TProxy для роутера и mixed proxy для ручных клиентов.
</p>
</div>
<div className="status-card">
<span className={state?.singboxRunning ? 'dot on' : 'dot'} />
<div>
<strong>{state?.singboxRunning ? 'sing-box running' : 'sing-box standby'}</strong>
<small>{state?.selectedTag || 'сервер не выбран'}</small>
</div>
</div>
</section>
<section className="grid">
<div className="panel primary-flow">
<div className="section-title">
<span>1</span>
<h2>Subscription</h2>
</div>
<label className="field">
<span>Subscription URL</span>
<input
value={subscriptionUrl}
onChange={(event) => setSubscriptionUrl(event.target.value)}
placeholder="https://provider.example/sub/..."
/>
</label>
<button className="button" disabled={busy || !subscriptionUrl} onClick={fetchServers}>
{busy ? 'Working...' : 'Parse subscription'}
</button>
<div className="section-title compact">
<span>2</span>
<h2>Servers</h2>
</div>
<div className="server-list">
{servers.length === 0 && <div className="empty">Серверы еще не загружены</div>}
{servers.map((server) => (
<button
key={server.tag}
className={server.tag === selectedTag ? 'server active' : 'server'}
onClick={() => setSelectedTag(server.tag)}
>
<strong>{server.tag}</strong>
<small>{server.type} / {server.server}:{server.server_port}</small>
</button>
))}
</div>
<button className="button apply" disabled={busy || !selectedTag} onClick={applyServer}>
Apply selected gateway route
</button>
{error && <div className="error">{error}</div>}
</div>
<aside className="panel details">
<div className="section-title">
<span>3</span>
<h2>Gateway runtime</h2>
</div>
<dl>
<div><dt>UI</dt><dd>:{state?.port || 3456}</dd></div>
<div><dt>Mixed proxy</dt><dd>:{state?.proxyPort || 8080}</dd></div>
<div><dt>TProxy</dt><dd>:{state?.tproxyPort || 7895}</dd></div>
<div><dt>RU direct</dt><dd>{state?.routingRuDirect ? 'enabled' : 'disabled'}</dd></div>
<div><dt>Traffic</dt><dd>{userTraffic}</dd></div>
</dl>
<div className="route-card">
<span>Routing policy</span>
<p>private IP -> direct</p>
<p>geoip-ru/geosite-category-ru -> direct</p>
<p>everything else -> selected VPN outbound</p>
</div>
<div className="logs">
{log.length === 0 && <p>Waiting for actions...</p>}
{log.map((entry) => (
<p key={`${entry.time}-${entry.message}`}><span>{entry.time}</span> {entry.message}</p>
))}
</div>
</aside>
</section>
<section className="panel rules-panel">
<div className="rules-header">
<div className="section-title">
<span>4</span>
<h2>Routing lists</h2>
</div>
<div className="rules-actions">
<button className="ghost-button" type="button" onClick={addRule}>Add list</button>
<button className="ghost-button solid" type="button" disabled={busy || rulesSaveStatus === 'saving'} onClick={saveRulesNow}>
{rulesSaveStatus === 'saving' ? 'Saving...' : rulesSaveStatus === 'pending' ? 'Save now' : rulesSaveStatus === 'error' ? 'Retry save' : 'Saved'}
</button>
</div>
</div>
<p className="rules-note">
Эти правила автосохраняются после изменений и вставляются после safety private-direct и до стандартного RU-direct. Для игр в gateway-режиме указывай домены, suffix, CIDR или порты: процесс на клиентском ПК gateway не видит.
</p>
<div className="rule-grid">
{customRules.length === 0 && (
<div className="empty rule-empty">
Нет пользовательских списков. Добавь список, например `League direct`, и отправь его в `direct`.
</div>
)}
{customRules.map((rule) => (
<article className="rule-card" key={rule.id}>
<div className="rule-top">
<input
value={rule.name}
onChange={(event) => updateRule(rule.id, { name: event.target.value })}
placeholder="Название списка"
/>
<label className="checkbox-label">
<input
type="checkbox"
checked={rule.enabled}
onChange={(event) => updateRule(rule.id, { enabled: event.target.checked })}
/>
enabled
</label>
</div>
<label className="field">
<span>Route to</span>
<select value={rule.outbound} onChange={(event) => updateRule(rule.id, { outbound: event.target.value })}>
<option value="direct">direct</option>
<option value="vpn">vpn</option>
<option value="block">block</option>
</select>
</label>
<div className="rule-fields">
<label className="field">
<span>Domains exact</span>
<textarea
value={listToText(rule.domains)}
onChange={(event) => updateRule(rule.id, { domains: textToList(event.target.value) })}
placeholder="riotgames.com"
/>
</label>
<label className="field">
<span>Domain suffixes</span>
<textarea
value={listToText(rule.domainSuffixes)}
onChange={(event) => updateRule(rule.id, { domainSuffixes: textToList(event.target.value) })}
placeholder={'leagueoflegends.com\nriotcdn.net'}
/>
</label>
<label className="field">
<span>IP CIDR</span>
<textarea
value={listToText(rule.ipCidrs)}
onChange={(event) => updateRule(rule.id, { ipCidrs: textToList(event.target.value) })}
placeholder="104.160.128.0/19"
/>
</label>
<label className="field">
<span>Ports</span>
<textarea
value={listToText(rule.ports)}
onChange={(event) => updateRule(rule.id, { ports: textToList(event.target.value) })}
placeholder={'5000\n5223'}
/>
</label>
</div>
<div className="rule-footer">
<label className="checkbox-label">
<input
type="checkbox"
checked={(rule.networks || []).includes('tcp')}
onChange={(event) => {
const set = new Set(rule.networks || []);
event.target.checked ? set.add('tcp') : set.delete('tcp');
updateRule(rule.id, { networks: Array.from(set) });
}}
/>
tcp
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={(rule.networks || []).includes('udp')}
onChange={(event) => {
const set = new Set(rule.networks || []);
event.target.checked ? set.add('udp') : set.delete('udp');
updateRule(rule.id, { networks: Array.from(set) });
}}
/>
udp
</label>
<button className="danger-button" type="button" onClick={() => removeRule(rule.id)}>
Remove
</button>
</div>
</article>
))}
</div>
</section>
</main>
);
}
createRoot(document.getElementById('root')).render(<App />);

440
src/web/styles.css Normal file
View File

@@ -0,0 +1,440 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap');
:root {
color-scheme: dark;
--bg: #07110d;
--panel: rgba(12, 28, 22, 0.86);
--panel-strong: rgba(18, 45, 34, 0.94);
--line: rgba(129, 255, 188, 0.2);
--text: #e8fff2;
--muted: #91b8a2;
--green: #7cffb2;
--amber: #ffd166;
--red: #ff6b6b;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.42);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: 'IBM Plex Sans', sans-serif;
color: var(--text);
background:
radial-gradient(circle at 20% 10%, rgba(124, 255, 178, 0.16), transparent 32rem),
radial-gradient(circle at 85% 0%, rgba(255, 209, 102, 0.1), transparent 26rem),
linear-gradient(140deg, #06100c 0%, #0a1711 48%, #050806 100%);
}
button,
input,
select,
textarea {
font: inherit;
}
.shell {
width: min(1180px, calc(100vw - 32px));
margin: 0 auto;
padding: 32px 0;
}
.panel {
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
border-radius: 28px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: stretch;
padding: 32px;
margin-bottom: 20px;
}
.eyebrow {
margin: 0 0 12px;
color: var(--green);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 12px;
font-weight: 700;
}
h1,
h2 {
font-family: 'Space Grotesk', sans-serif;
margin: 0;
}
h1 {
max-width: 780px;
font-size: clamp(38px, 7vw, 76px);
line-height: 0.92;
letter-spacing: -0.06em;
}
.lead {
max-width: 740px;
color: var(--muted);
font-size: 17px;
line-height: 1.55;
}
.status-card {
min-width: 240px;
display: flex;
gap: 14px;
align-items: center;
align-self: flex-start;
padding: 18px;
border-radius: 20px;
background: var(--panel-strong);
border: 1px solid var(--line);
}
.status-card strong,
.status-card small {
display: block;
}
.status-card small {
color: var(--muted);
margin-top: 4px;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--amber);
box-shadow: 0 0 18px rgba(255, 209, 102, 0.65);
}
.dot.on {
background: var(--green);
box-shadow: 0 0 18px rgba(124, 255, 178, 0.75);
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
gap: 20px;
}
.primary-flow,
.details {
padding: 24px;
}
.section-title {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.section-title.compact {
margin-top: 26px;
}
.section-title span {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--green);
color: #07110d;
font-weight: 800;
}
.section-title h2 {
font-size: 24px;
}
.field {
display: grid;
gap: 8px;
color: var(--muted);
font-weight: 600;
}
input {
width: 100%;
border: 1px solid var(--line);
background: rgba(1, 8, 5, 0.72);
color: var(--text);
padding: 16px 18px;
border-radius: 18px;
outline: none;
}
input:focus {
border-color: var(--green);
box-shadow: 0 0 0 4px rgba(124, 255, 178, 0.1);
}
select,
textarea {
width: 100%;
border: 1px solid var(--line);
background: rgba(1, 8, 5, 0.72);
color: var(--text);
padding: 12px 14px;
border-radius: 16px;
outline: none;
}
textarea {
min-height: 92px;
resize: vertical;
}
select:focus,
textarea:focus {
border-color: var(--green);
box-shadow: 0 0 0 4px rgba(124, 255, 178, 0.1);
}
.button {
margin-top: 14px;
width: 100%;
border: 0;
border-radius: 18px;
padding: 16px 18px;
background: var(--green);
color: #07110d;
font-weight: 800;
cursor: pointer;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.button.apply {
background: linear-gradient(135deg, var(--green), #d8ff78);
}
.server-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 12px;
}
.server,
.empty {
border: 1px solid var(--line);
background: rgba(1, 8, 5, 0.48);
color: var(--text);
text-align: left;
border-radius: 18px;
padding: 14px;
}
.server {
cursor: pointer;
}
.server.active {
border-color: var(--green);
background: rgba(124, 255, 178, 0.12);
}
.server strong,
.server small {
display: block;
}
.server small {
color: var(--muted);
margin-top: 8px;
}
.error {
margin-top: 14px;
color: var(--red);
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.24);
padding: 12px 14px;
border-radius: 16px;
}
dl {
display: grid;
gap: 10px;
margin: 0;
}
dl div {
display: flex;
justify-content: space-between;
gap: 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
dt {
color: var(--muted);
}
dd {
margin: 0;
font-weight: 700;
}
.route-card,
.logs {
margin-top: 20px;
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(1, 8, 5, 0.36);
}
.route-card span {
color: var(--green);
font-weight: 800;
}
.route-card p,
.logs p {
margin: 8px 0 0;
color: var(--muted);
}
.logs span {
color: var(--green);
font-variant-numeric: tabular-nums;
}
.rules-panel {
margin-top: 20px;
padding: 24px;
}
.rules-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.rules-actions {
display: flex;
gap: 10px;
}
.ghost-button,
.danger-button {
border: 1px solid var(--line);
color: var(--text);
background: rgba(1, 8, 5, 0.45);
border-radius: 14px;
padding: 10px 14px;
font-weight: 800;
cursor: pointer;
}
.ghost-button.solid {
background: var(--green);
color: #07110d;
border-color: var(--green);
}
.danger-button {
border-color: rgba(255, 107, 107, 0.35);
color: var(--red);
}
.rules-note {
margin: -6px 0 18px;
color: var(--muted);
line-height: 1.5;
}
.rule-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.rule-empty {
grid-column: 1 / -1;
}
.rule-card {
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 22px;
background: rgba(1, 8, 5, 0.36);
}
.rule-top,
.rule-footer {
display: flex;
gap: 12px;
align-items: center;
}
.rule-top input {
padding: 12px 14px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-weight: 700;
white-space: nowrap;
}
.checkbox-label input {
width: auto;
}
.rule-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 860px) {
.hero,
.grid {
grid-template-columns: 1fr;
}
.hero {
flex-direction: column;
}
.status-card {
width: 100%;
}
.rules-header,
.rule-top,
.rule-footer {
align-items: stretch;
flex-direction: column;
}
.rules-actions,
.rule-fields {
grid-template-columns: 1fr;
flex-direction: column;
}
}