Add direct gateway forwarding when VPN is off
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 1s

This commit is contained in:
2026-07-11 11:37:08 +03:00
parent 9d4f312595
commit 41922ad30b
14 changed files with 264 additions and 46 deletions

View File

@@ -15,6 +15,7 @@ export const settings = {
port: parsePort(process.env.PORT, 3456),
proxyPort,
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
dataDir,
distDir: process.env.DIST_DIR || "/app/dist",

View File

@@ -0,0 +1,23 @@
import { spawnSync } from 'node:child_process';
const options = { encoding: 'utf8' };
export function setGatewayInterception(enabled, chain, run = spawnSync) {
const rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain];
const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0;
if (!enabled) {
if (exists) run('iptables', [...rule.slice(0, 3), '-D', ...rule.slice(3)], options);
return;
}
if (exists) return;
const result = run(
'iptables',
[...rule.slice(0, 3), '-I', 'PREROUTING', '1', '-j', chain],
options,
);
if (result.status !== 0) {
throw new Error((result.stderr || 'Не удалось включить Gateway VPN').trim());
}
}

View File

@@ -3,6 +3,7 @@ import http from 'node:http';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { settings } from './config.js';
import { setGatewayInterception } from './gatewayRouting.js';
import { tcpPing } from './ping.js';
import { buildSharedProxyInfo } from './sharedProxy.js';
import {
@@ -90,6 +91,9 @@ function checkSingboxConfig() {
function stopSingbox() {
return new Promise((resolve) => {
if (settings.appMode === 'gateway') {
setGatewayInterception(false, settings.tproxyChain);
}
if (!singboxProcess) {
singboxStartedAt = null;
return resolve();
@@ -112,7 +116,12 @@ function stopSingbox() {
}
async function startSingbox() {
if (!fs.existsSync(settings.configPath)) return false;
if (!fs.existsSync(settings.configPath)) {
if (settings.appMode === 'gateway') {
setGatewayInterception(false, settings.tproxyChain);
}
return false;
}
checkSingboxConfig();
await stopSingbox();
@@ -121,10 +130,23 @@ async function startSingbox() {
});
singboxProcess = child;
singboxStartedAt = new Date().toISOString();
try {
if (settings.appMode === 'gateway') {
setGatewayInterception(true, settings.tproxyChain);
}
} catch (error) {
child.kill('SIGTERM');
singboxProcess = null;
singboxStartedAt = null;
throw error;
}
child.once('exit', () => {
if (singboxProcess === child) {
singboxProcess = null;
singboxStartedAt = null;
if (settings.appMode === 'gateway') {
setGatewayInterception(false, settings.tproxyChain);
}
}
});
return true;

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import {
connectionAction,
copyText,
formatConnectionDuration,
localProxyUrls,
subscriptionDomain,
@@ -35,13 +36,15 @@ export function ClientOverviewPage({
const [now, setNow] = useState(Date.now());
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [pings, setPings] = useState({});
const [copiedProxy, setCopiedProxy] = useState('');
const [accessTab, setAccessTab] = useState('gateway');
const [copyFeedback, setCopyFeedback] = useState(null);
const [refreshingInfo, setRefreshingInfo] = useState(false);
const [usageUpdated, setUsageUpdated] = useState(false);
const [serverRevealVersion, setServerRevealVersion] = useState(0);
const [serversLeaving, setServersLeaving] = useState(false);
const subscriptionInputRef = useRef(null);
const subscriptionRef = useRef(null);
const copyTimerRef = useRef(null);
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
@@ -133,6 +136,8 @@ export function ClientOverviewPage({
return () => cancelAnimationFrame(frame);
}, [usage.used]);
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
async function toggleConnection() {
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
if (action?.type === 'stop') return onStop();
@@ -154,13 +159,14 @@ export function ClientOverviewPage({
}
async function copyProxy(kind) {
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
clearTimeout(copyTimerRef.current);
setCopyFeedback({ kind, failed: false });
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
try {
await navigator.clipboard.writeText(kind === 'gateway' ? gatewayAddress : proxyUrls[kind]);
setCopiedProxy(kind);
setTimeout(() => setCopiedProxy(''), 800);
await copyText(value);
} catch {
setCopiedProxy('error');
setTimeout(() => setCopiedProxy(''), 800);
setCopyFeedback({ kind, failed: true });
}
}
@@ -194,7 +200,7 @@ export function ClientOverviewPage({
type="button"
role="switch"
aria-checked={connected}
aria-label={connected ? `Выключить ${isGateway ? 'Gateway' : 'VPN'}` : `Включить ${isGateway ? 'Gateway' : 'VPN'}`}
aria-label={connected ? 'Выключить VPN' : 'Включить VPN'}
disabled={busy || (!connected && !canStart)}
onClick={toggleConnection}
>
@@ -204,9 +210,7 @@ export function ClientOverviewPage({
</button>
<div className="client-state-copy" aria-live="polite">
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
{isGateway
? connected ? 'Gateway включён' : 'Gateway выключен'
: connected ? 'VPN включён' : 'VPN выключен'}
{connected ? 'VPN включён' : 'VPN выключен'}
</h2>
<div className="client-state-detail">
{connected ? (
@@ -221,24 +225,42 @@ export function ClientOverviewPage({
</div>
</div>
<section className="client-proxies" aria-label={isGateway ? 'Адреса Gateway и Proxy' : 'Локальный прокси'}>
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Local Proxy' : 'Локальный прокси'}>
{isGateway && (
<div className="client-access-point">
<span className="client-proxy-label">Gateway</span>
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
{[
['gateway', 'Gateway'],
['proxy', 'Local Proxy'],
].map(([tab, label]) => (
<button
className={`client-access-tab${accessTab === tab ? ' is-active' : ''}`}
type="button"
role="tab"
key={tab}
aria-selected={accessTab === tab}
onClick={() => setAccessTab(tab)}
>
{label}
</button>
))}
</div>
)}
{(isGateway ? accessTab === 'gateway' : false) ? (
<div className="client-access-point" role="tabpanel" key="gateway">
<strong className="client-proxy-address">{gatewayAddress}</strong>
<button
className={copiedProxy === 'gateway' ? 'is-copied' : ''}
className={`client-copy-button${copyFeedback?.kind === 'gateway' ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
onClick={() => copyProxy('gateway')}
>
<span className="client-copy-label">КОПИРОВАТЬ</span>
{copiedProxy === 'gateway' && <span className="client-copy-feedback">Copied</span>}
{copyFeedback?.kind === 'gateway' && <span className="client-copy-feedback">{copyFeedback.failed ? 'Error' : 'Copied'}</span>}
</button>
</div>
)}
<div className="client-access-point">
<span className="client-proxy-label">{isGateway ? 'Proxy' : 'Адрес'}</span>
) : (
<div className="client-access-point" role={isGateway ? 'tabpanel' : undefined} key="proxy">
{!isGateway && <span className="client-proxy-label">Адрес</span>}
<strong className="client-proxy-address">
{proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
@@ -248,18 +270,19 @@ export function ClientOverviewPage({
['http', 'HTTP'],
].map(([kind, label]) => (
<button
className={copiedProxy === kind ? 'is-copied' : ''}
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
key={kind}
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
onClick={() => copyProxy(kind)}
>
<span className="client-copy-label">{label}</span>
{copiedProxy === kind && <span className="client-copy-feedback">Copied</span>}
{copyFeedback?.kind === kind && <span className="client-copy-feedback">{copyFeedback.failed ? 'Error' : 'Copied'}</span>}
</button>
))}
</div>
</div>
)}
</section>
</section>
)}

View File

@@ -66,10 +66,12 @@ p {
}
.app-body.client-mode {
min-height: 100vh;
background: var(--client-bg);
}
.client-mode .app-main {
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px 32px;
@@ -724,10 +726,14 @@ p {
display: grid;
justify-items: center;
gap: 5px;
width: 220px;
width: 240px;
margin-top: 6px;
}
.client-proxies.has-tabs {
min-height: 91px;
}
.client-proxy-label {
color: var(--client-muted);
font-size: 9px;
@@ -747,10 +753,44 @@ p {
display: grid;
justify-items: center;
gap: 5px;
animation: client-access-reveal 450ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-access-point + .client-access-point {
margin-top: 12px;
@keyframes client-access-reveal {
0% { opacity: 0; filter: blur(4px); }
100% { opacity: 1; filter: blur(0); }
}
.client-access-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
width: 220px;
margin-bottom: 8px;
border-bottom: 1px solid var(--client-border);
}
.client-access-tabs .client-access-tab {
width: auto;
padding: 6px 4px 7px;
border: 0;
border-bottom: 1px solid transparent;
background: transparent;
color: var(--client-muted);
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
letter-spacing: 0.06em;
cursor: pointer;
transition: color 220ms ease, border-color 300ms ease, filter 300ms ease;
}
.client-access-tabs .client-access-tab.is-active {
border-bottom-color: var(--client-accent);
color: var(--client-text);
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 45%, transparent));
}
.client-access-tab:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-proxy-actions {
@@ -770,7 +810,7 @@ p {
transform: translateX(-50%);
}
.client-proxies button {
.client-copy-button {
position: relative;
width: 86px;
padding: 6px 10px;
@@ -788,7 +828,8 @@ p {
transition: opacity 100ms ease;
}
.client-proxies button.is-copied .client-copy-label {
.client-copy-button.is-copied .client-copy-label,
.client-copy-button.is-copy-error .client-copy-label {
opacity: 0;
}
@@ -801,27 +842,32 @@ p {
animation: client-copy-fade 800ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.client-copy-button.is-copy-error .client-copy-feedback {
color: oklch(0.68 0.15 28);
filter: drop-shadow(0 0 5px oklch(0.68 0.15 28 / 0.45));
}
@keyframes client-copy-fade {
0%, 18% { opacity: 1; filter: drop-shadow(0 0 5px var(--client-accent)); }
100% { opacity: 0; filter: drop-shadow(0 0 0 transparent); }
}
.client-proxies button:hover {
.client-copy-button:hover {
color: var(--client-text);
opacity: 1;
transform: translateY(-2px);
}
.client-proxies button:active {
.client-copy-button:active {
transform: translateY(0) scale(0.98);
}
.client-proxies button.is-copied {
.client-copy-button.is-copied {
opacity: 1;
color: var(--client-accent);
}
.client-proxies button:focus-visible {
.client-copy-button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 3px;
}
@@ -877,7 +923,9 @@ p {
.client-usage,
.client-usage > strong,
.client-server,
.client-proxies button,
.client-copy-button,
.client-access-tab,
.client-access-point,
.client-copy-feedback,
.client-subscription-edit,
.client-subscription-edit::after,

View File

@@ -36,6 +36,27 @@ export function localProxyUrls(port = 8082, host = '127.0.0.1') {
};
}
export async function copyText(text, options = {}) {
const clipboard = options.clipboard ?? globalThis.navigator?.clipboard;
const documentRef = options.documentRef ?? globalThis.document;
if (documentRef?.execCommand) {
const textarea = documentRef.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
documentRef.body.append(textarea);
textarea.select();
const copied = documentRef.execCommand('copy');
textarea.remove();
if (copied) return;
}
if (!clipboard?.writeText) throw new Error('Copy failed');
await clipboard.writeText(text);
}
export function subscriptionUsage(userInfo = {}) {
const upload = Math.max(0, Number(userInfo.upload) || 0);
const download = Math.max(0, Number(userInfo.download) || 0);