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

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 />);