Add direct gateway forwarding when VPN is off
This commit is contained in:
@@ -12,4 +12,7 @@ TPROXY_PORT=7895
|
|||||||
TPROXY_MARK=1
|
TPROXY_MARK=1
|
||||||
TPROXY_TABLE=100
|
TPROXY_TABLE=100
|
||||||
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
|
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
|
||||||
|
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
|
||||||
|
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ COPY dist /app/dist
|
|||||||
|
|
||||||
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
||||||
apt-get update \
|
apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs dumb-init \
|
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 nodejs dumb-init \
|
||||||
&& rm -rf /var/lib/apt/lists/*; \
|
&& rm -rf /var/lib/apt/lists/*; \
|
||||||
else \
|
else \
|
||||||
command -v dumb-init >/dev/null \
|
command -v dumb-init >/dev/null \
|
||||||
&& command -v node >/dev/null \
|
&& command -v node >/dev/null \
|
||||||
&& command -v iptables >/dev/null \
|
&& command -v iptables >/dev/null; \
|
||||||
&& command -v ipset >/dev/null; \
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \
|
||||||
@@ -45,8 +44,6 @@ ENV PORT=3456 \
|
|||||||
PROXY_PORT=8080 \
|
PROXY_PORT=8080 \
|
||||||
PROXY_BIND_IP=0.0.0.0 \
|
PROXY_BIND_IP=0.0.0.0 \
|
||||||
TPROXY_PORT=7895 \
|
TPROXY_PORT=7895 \
|
||||||
DIRECT_BYPASS_CACHE=false \
|
|
||||||
RULE_SET_DOWNLOAD_DETOUR=vpn \
|
|
||||||
DATA_DIR=/var/lib/vpn-proxy \
|
DATA_DIR=/var/lib/vpn-proxy \
|
||||||
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
SING_BOX_CONFIG=/etc/sing-box/config.json \
|
||||||
SING_BOX_CACHE=/var/lib/sing-box/cache.db
|
SING_BOX_CACHE=/var/lib/sing-box/cache.db
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Один компактный VPN-клиент в двух режимах:
|
Один компактный VPN-клиент в двух режимах:
|
||||||
|
|
||||||
- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или HTTP/SOCKS5 Proxy;
|
- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или Local HTTP/SOCKS5 Proxy;
|
||||||
- `client` — локальный proxy-клиент для macOS.
|
- `client` — локальный proxy-клиент для macOS.
|
||||||
|
|
||||||
В обоих режимах пользователь добавляет подписку, выбирает сервер и включает VPN на одном экране.
|
В обоих режимах пользователь добавляет подписку, выбирает сервер и включает VPN на одном экране.
|
||||||
@@ -19,9 +19,9 @@ docker compose -f docker-compose.gateway.yml up -d --build
|
|||||||
После подключения экран показывает:
|
После подключения экран показывает:
|
||||||
|
|
||||||
- `Gateway` — адрес, который можно назначить устройству как основной шлюз;
|
- `Gateway` — адрес, который можно назначить устройству как основной шлюз;
|
||||||
- `Proxy` — один адрес на порту `8080`, доступный как `HTTP` и `SOCKS5`.
|
- `Local Proxy` — один адрес на порту `8080`, доступный как `HTTP` и `SOCKS5`.
|
||||||
|
|
||||||
Весь перехваченный публичный TCP/UDP и весь proxy-трафик идут через выбранный VPN. Приватные и локальные сети не перехватываются, чтобы сохранить доступ к Gateway и LAN.
|
Когда VPN включён, публичный трафик Gateway и Local Proxy идёт через выбранный sing-box outbound. Когда VPN выключен, TProxy-перехват снимается и Gateway продолжает работать напрямую через kernel forwarding/NAT без прохода через sing-box.
|
||||||
|
|
||||||
Proxy по умолчанию разрешён только из приватных сетей. Диапазоны задаются через `PROXY_ALLOWED_CIDRS`.
|
Proxy по умолчанию разрешён только из приватных сетей. Диапазоны задаются через `PROXY_ALLOWED_CIDRS`.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ TPROXY_PORT="${TPROXY_PORT:-7895}"
|
|||||||
TPROXY_MARK="${TPROXY_MARK:-1}"
|
TPROXY_MARK="${TPROXY_MARK:-1}"
|
||||||
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
||||||
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
||||||
|
GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}"
|
||||||
|
GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}"
|
||||||
|
GATEWAY_CLIENT_CIDRS="${GATEWAY_CLIENT_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||||
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
||||||
@@ -35,6 +38,15 @@ cleanup_tproxy() {
|
|||||||
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup_gateway_forwarding() {
|
||||||
|
ipt -D FORWARD -j "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -D POSTROUTING -j "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -F "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -X "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -F "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
|
||||||
|
ipt -t nat -X "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
enable_ip_forwarding() {
|
enable_ip_forwarding() {
|
||||||
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
|
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
|
||||||
printf '1' > /proc/sys/net/ipv4/ip_forward || true
|
printf '1' > /proc/sys/net/ipv4/ip_forward || true
|
||||||
@@ -58,6 +70,22 @@ setup_proxy_firewall() {
|
|||||||
ipt -I INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
|
ipt -I INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setup_gateway_forwarding() {
|
||||||
|
log "setup direct gateway forwarding"
|
||||||
|
cleanup_gateway_forwarding
|
||||||
|
enable_ip_forwarding
|
||||||
|
|
||||||
|
ipt -N "$GATEWAY_FORWARD_CHAIN"
|
||||||
|
ipt -t nat -N "$GATEWAY_NAT_CHAIN"
|
||||||
|
for cidr in $GATEWAY_CLIENT_CIDRS; do
|
||||||
|
ipt -A "$GATEWAY_FORWARD_CHAIN" -s "$cidr" -j ACCEPT
|
||||||
|
ipt -A "$GATEWAY_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
ipt -t nat -A "$GATEWAY_NAT_CHAIN" -s "$cidr" -m addrtype ! --dst-type LOCAL -j MASQUERADE
|
||||||
|
done
|
||||||
|
ipt -I FORWARD 1 -j "$GATEWAY_FORWARD_CHAIN"
|
||||||
|
ipt -t nat -I POSTROUTING 1 -j "$GATEWAY_NAT_CHAIN"
|
||||||
|
}
|
||||||
|
|
||||||
setup_tproxy() {
|
setup_tproxy() {
|
||||||
log "setup tproxy on port ${TPROXY_PORT}"
|
log "setup tproxy on port ${TPROXY_PORT}"
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
@@ -76,9 +104,9 @@ setup_tproxy() {
|
|||||||
|
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||||
ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setup_gateway_forwarding
|
||||||
setup_tproxy
|
setup_tproxy
|
||||||
setup_proxy_firewall
|
setup_proxy_firewall
|
||||||
|
|
||||||
@@ -90,6 +118,7 @@ shutdown() {
|
|||||||
wait "$APP_PID" 2>/dev/null || true
|
wait "$APP_PID" 2>/dev/null || true
|
||||||
cleanup_proxy_firewall
|
cleanup_proxy_firewall
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
|
cleanup_gateway_forwarding
|
||||||
}
|
}
|
||||||
|
|
||||||
trap 'shutdown; exit 0' SIGTERM SIGINT
|
trap 'shutdown; exit 0' SIGTERM SIGINT
|
||||||
@@ -97,4 +126,5 @@ wait "$APP_PID"
|
|||||||
STATUS=$?
|
STATUS=$?
|
||||||
cleanup_proxy_firewall
|
cleanup_proxy_firewall
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
|
cleanup_gateway_forwarding
|
||||||
exit "$STATUS"
|
exit "$STATUS"
|
||||||
|
|||||||
@@ -47,11 +47,9 @@ TPROXY_PORT=7895
|
|||||||
TPROXY_MARK=1
|
TPROXY_MARK=1
|
||||||
TPROXY_TABLE=100
|
TPROXY_TABLE=100
|
||||||
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
TPROXY_CHAIN=VPN_PROXY_TPROXY
|
||||||
TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_SRC_BYPASS
|
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
|
||||||
TPROXY_SOURCE_FORWARD_CHAIN=VPN_PROXY_FWD_BYPASS
|
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
|
||||||
TPROXY_SOURCE_NAT_CHAIN=VPN_PROXY_NAT_BYPASS
|
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||||
TPROXY_BYPASS_SOURCE_CIDRS=
|
|
||||||
ROUTING_RU_DIRECT=true
|
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
EOF
|
EOF
|
||||||
echo "Created default .env. Edit ${DEPLOY_PATH}/.env if this server needs different ports."
|
echo "Created default .env. Edit ${DEPLOY_PATH}/.env if this server needs different ports."
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const settings = {
|
|||||||
port: parsePort(process.env.PORT, 3456),
|
port: parsePort(process.env.PORT, 3456),
|
||||||
proxyPort,
|
proxyPort,
|
||||||
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
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",
|
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
||||||
dataDir,
|
dataDir,
|
||||||
distDir: process.env.DIST_DIR || "/app/dist",
|
distDir: process.env.DIST_DIR || "/app/dist",
|
||||||
|
|||||||
23
src/server/gatewayRouting.js
Normal file
23
src/server/gatewayRouting.js
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import http from 'node:http';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { settings } from './config.js';
|
import { settings } from './config.js';
|
||||||
|
import { setGatewayInterception } from './gatewayRouting.js';
|
||||||
import { tcpPing } from './ping.js';
|
import { tcpPing } from './ping.js';
|
||||||
import { buildSharedProxyInfo } from './sharedProxy.js';
|
import { buildSharedProxyInfo } from './sharedProxy.js';
|
||||||
import {
|
import {
|
||||||
@@ -90,6 +91,9 @@ function checkSingboxConfig() {
|
|||||||
|
|
||||||
function stopSingbox() {
|
function stopSingbox() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
if (settings.appMode === 'gateway') {
|
||||||
|
setGatewayInterception(false, settings.tproxyChain);
|
||||||
|
}
|
||||||
if (!singboxProcess) {
|
if (!singboxProcess) {
|
||||||
singboxStartedAt = null;
|
singboxStartedAt = null;
|
||||||
return resolve();
|
return resolve();
|
||||||
@@ -112,7 +116,12 @@ function stopSingbox() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startSingbox() {
|
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();
|
checkSingboxConfig();
|
||||||
await stopSingbox();
|
await stopSingbox();
|
||||||
|
|
||||||
@@ -121,10 +130,23 @@ async function startSingbox() {
|
|||||||
});
|
});
|
||||||
singboxProcess = child;
|
singboxProcess = child;
|
||||||
singboxStartedAt = new Date().toISOString();
|
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', () => {
|
child.once('exit', () => {
|
||||||
if (singboxProcess === child) {
|
if (singboxProcess === child) {
|
||||||
singboxProcess = null;
|
singboxProcess = null;
|
||||||
singboxStartedAt = null;
|
singboxStartedAt = null;
|
||||||
|
if (settings.appMode === 'gateway') {
|
||||||
|
setGatewayInterception(false, settings.tproxyChain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import {
|
import {
|
||||||
connectionAction,
|
connectionAction,
|
||||||
|
copyText,
|
||||||
formatConnectionDuration,
|
formatConnectionDuration,
|
||||||
localProxyUrls,
|
localProxyUrls,
|
||||||
subscriptionDomain,
|
subscriptionDomain,
|
||||||
@@ -35,13 +36,15 @@ export function ClientOverviewPage({
|
|||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||||
const [pings, setPings] = useState({});
|
const [pings, setPings] = useState({});
|
||||||
const [copiedProxy, setCopiedProxy] = useState('');
|
const [accessTab, setAccessTab] = useState('gateway');
|
||||||
|
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||||
const [refreshingInfo, setRefreshingInfo] = useState(false);
|
const [refreshingInfo, setRefreshingInfo] = useState(false);
|
||||||
const [usageUpdated, setUsageUpdated] = useState(false);
|
const [usageUpdated, setUsageUpdated] = useState(false);
|
||||||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||||
const [serversLeaving, setServersLeaving] = useState(false);
|
const [serversLeaving, setServersLeaving] = useState(false);
|
||||||
const subscriptionInputRef = useRef(null);
|
const subscriptionInputRef = useRef(null);
|
||||||
const subscriptionRef = useRef(null);
|
const subscriptionRef = useRef(null);
|
||||||
|
const copyTimerRef = useRef(null);
|
||||||
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||||
@@ -133,6 +136,8 @@ export function ClientOverviewPage({
|
|||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [usage.used]);
|
}, [usage.used]);
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
||||||
|
|
||||||
async function toggleConnection() {
|
async function toggleConnection() {
|
||||||
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||||||
if (action?.type === 'stop') return onStop();
|
if (action?.type === 'stop') return onStop();
|
||||||
@@ -154,13 +159,14 @@ export function ClientOverviewPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyProxy(kind) {
|
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 {
|
try {
|
||||||
await navigator.clipboard.writeText(kind === 'gateway' ? gatewayAddress : proxyUrls[kind]);
|
await copyText(value);
|
||||||
setCopiedProxy(kind);
|
|
||||||
setTimeout(() => setCopiedProxy(''), 800);
|
|
||||||
} catch {
|
} catch {
|
||||||
setCopiedProxy('error');
|
setCopyFeedback({ kind, failed: true });
|
||||||
setTimeout(() => setCopiedProxy(''), 800);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +200,7 @@ export function ClientOverviewPage({
|
|||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={connected}
|
aria-checked={connected}
|
||||||
aria-label={connected ? `Выключить ${isGateway ? 'Gateway' : 'VPN'}` : `Включить ${isGateway ? 'Gateway' : 'VPN'}`}
|
aria-label={connected ? 'Выключить VPN' : 'Включить VPN'}
|
||||||
disabled={busy || (!connected && !canStart)}
|
disabled={busy || (!connected && !canStart)}
|
||||||
onClick={toggleConnection}
|
onClick={toggleConnection}
|
||||||
>
|
>
|
||||||
@@ -204,9 +210,7 @@ export function ClientOverviewPage({
|
|||||||
</button>
|
</button>
|
||||||
<div className="client-state-copy" aria-live="polite">
|
<div className="client-state-copy" aria-live="polite">
|
||||||
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
||||||
{isGateway
|
{connected ? 'VPN включён' : 'VPN выключен'}
|
||||||
? connected ? 'Gateway включён' : 'Gateway выключен'
|
|
||||||
: connected ? 'VPN включён' : 'VPN выключен'}
|
|
||||||
</h2>
|
</h2>
|
||||||
<div className="client-state-detail">
|
<div className="client-state-detail">
|
||||||
{connected ? (
|
{connected ? (
|
||||||
@@ -221,24 +225,42 @@ export function ClientOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{isGateway && (
|
||||||
<div className="client-access-point">
|
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
|
||||||
<span className="client-proxy-label">Gateway</span>
|
{[
|
||||||
|
['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>
|
<strong className="client-proxy-address">{gatewayAddress}</strong>
|
||||||
<button
|
<button
|
||||||
className={copiedProxy === 'gateway' ? 'is-copied' : ''}
|
className={`client-copy-button${copyFeedback?.kind === 'gateway' ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
|
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
|
||||||
onClick={() => copyProxy('gateway')}
|
onClick={() => copyProxy('gateway')}
|
||||||
>
|
>
|
||||||
<span className="client-copy-label">КОПИРОВАТЬ</span>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
<div className="client-access-point">
|
<div className="client-access-point" role={isGateway ? 'tabpanel' : undefined} key="proxy">
|
||||||
<span className="client-proxy-label">{isGateway ? 'Proxy' : 'Адрес'}</span>
|
{!isGateway && <span className="client-proxy-label">Адрес</span>}
|
||||||
<strong className="client-proxy-address">
|
<strong className="client-proxy-address">
|
||||||
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||||
</strong>
|
</strong>
|
||||||
@@ -248,18 +270,19 @@ export function ClientOverviewPage({
|
|||||||
['http', 'HTTP'],
|
['http', 'HTTP'],
|
||||||
].map(([kind, label]) => (
|
].map(([kind, label]) => (
|
||||||
<button
|
<button
|
||||||
className={copiedProxy === kind ? 'is-copied' : ''}
|
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
key={kind}
|
key={kind}
|
||||||
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
||||||
onClick={() => copyProxy(kind)}
|
onClick={() => copyProxy(kind)}
|
||||||
>
|
>
|
||||||
<span className="client-copy-label">{label}</span>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -66,10 +66,12 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-body.client-mode {
|
.app-body.client-mode {
|
||||||
|
min-height: 100vh;
|
||||||
background: var(--client-bg);
|
background: var(--client-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-mode .app-main {
|
.client-mode .app-main {
|
||||||
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 48px 24px 32px;
|
padding: 48px 24px 32px;
|
||||||
@@ -724,10 +726,14 @@ p {
|
|||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
width: 220px;
|
width: 240px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-proxies.has-tabs {
|
||||||
|
min-height: 91px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-proxy-label {
|
.client-proxy-label {
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
@@ -747,10 +753,44 @@ p {
|
|||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
|
animation: client-access-reveal 450ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-access-point + .client-access-point {
|
@keyframes client-access-reveal {
|
||||||
margin-top: 12px;
|
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 {
|
.client-proxy-actions {
|
||||||
@@ -770,7 +810,7 @@ p {
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-proxies button {
|
.client-copy-button {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 86px;
|
width: 86px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
@@ -788,7 +828,8 @@ p {
|
|||||||
transition: opacity 100ms ease;
|
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;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,27 +842,32 @@ p {
|
|||||||
animation: client-copy-fade 800ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
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 {
|
@keyframes client-copy-fade {
|
||||||
0%, 18% { opacity: 1; filter: drop-shadow(0 0 5px var(--client-accent)); }
|
0%, 18% { opacity: 1; filter: drop-shadow(0 0 5px var(--client-accent)); }
|
||||||
100% { opacity: 0; filter: drop-shadow(0 0 0 transparent); }
|
100% { opacity: 0; filter: drop-shadow(0 0 0 transparent); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-proxies button:hover {
|
.client-copy-button:hover {
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-proxies button:active {
|
.client-copy-button:active {
|
||||||
transform: translateY(0) scale(0.98);
|
transform: translateY(0) scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-proxies button.is-copied {
|
.client-copy-button.is-copied {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-proxies button:focus-visible {
|
.client-copy-button:focus-visible {
|
||||||
outline: 2px solid var(--client-accent);
|
outline: 2px solid var(--client-accent);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
@@ -877,7 +923,9 @@ p {
|
|||||||
.client-usage,
|
.client-usage,
|
||||||
.client-usage > strong,
|
.client-usage > strong,
|
||||||
.client-server,
|
.client-server,
|
||||||
.client-proxies button,
|
.client-copy-button,
|
||||||
|
.client-access-tab,
|
||||||
|
.client-access-point,
|
||||||
.client-copy-feedback,
|
.client-copy-feedback,
|
||||||
.client-subscription-edit,
|
.client-subscription-edit,
|
||||||
.client-subscription-edit::after,
|
.client-subscription-edit::after,
|
||||||
|
|||||||
@@ -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 = {}) {
|
export function subscriptionUsage(userInfo = {}) {
|
||||||
const upload = Math.max(0, Number(userInfo.upload) || 0);
|
const upload = Math.max(0, Number(userInfo.upload) || 0);
|
||||||
const download = Math.max(0, Number(userInfo.download) || 0);
|
const download = Math.max(0, Number(userInfo.download) || 0);
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ const entrypoint = fs.readFileSync(
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
|
|
||||||
test('gateway intercepts all public TCP and UDP traffic without source bypasses', () => {
|
test('gateway keeps direct forwarding active while TProxy interception is switchable', () => {
|
||||||
assert.match(entrypoint, /-p tcp -j TPROXY --on-port "\$TPROXY_PORT"/);
|
assert.match(entrypoint, /-p tcp -j TPROXY --on-port "\$TPROXY_PORT"/);
|
||||||
assert.match(entrypoint, /-p udp -j TPROXY --on-port "\$TPROXY_PORT"/);
|
assert.match(entrypoint, /-p udp -j TPROXY --on-port "\$TPROXY_PORT"/);
|
||||||
|
assert.match(entrypoint, /-I FORWARD 1 -j "\$GATEWAY_FORWARD_CHAIN"/);
|
||||||
|
assert.match(entrypoint, /-I POSTROUTING 1 -j "\$GATEWAY_NAT_CHAIN"/);
|
||||||
|
assert.doesNotMatch(entrypoint, /-A PREROUTING -j "\$TPROXY_CHAIN"/);
|
||||||
assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/);
|
assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/);
|
||||||
});
|
});
|
||||||
|
|||||||
28
test/server/gateway-routing.test.js
Normal file
28
test/server/gateway-routing.test.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { setGatewayInterception } from '../../src/server/gatewayRouting.js';
|
||||||
|
|
||||||
|
test('gateway switches only the TProxy PREROUTING jump', () => {
|
||||||
|
const calls = [];
|
||||||
|
const missing = (command, args) => {
|
||||||
|
calls.push([command, args]);
|
||||||
|
return { status: args.includes('-C') ? 1 : 0, stderr: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
setGatewayInterception(true, 'VPN_PROXY_TPROXY', missing);
|
||||||
|
assert.deepEqual(calls.map(([, args]) => args), [
|
||||||
|
['-w', '-t', 'mangle', '-C', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'],
|
||||||
|
['-w', '-t', 'mangle', '-I', 'PREROUTING', '1', '-j', 'VPN_PROXY_TPROXY'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
const existing = (command, args) => {
|
||||||
|
calls.push([command, args]);
|
||||||
|
return { status: 0, stderr: '' };
|
||||||
|
};
|
||||||
|
setGatewayInterception(false, 'VPN_PROXY_TPROXY', existing);
|
||||||
|
assert.deepEqual(calls.map(([, args]) => args), [
|
||||||
|
['-w', '-t', 'mangle', '-C', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'],
|
||||||
|
['-w', '-t', 'mangle', '-D', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
connectionAction,
|
connectionAction,
|
||||||
|
copyText,
|
||||||
formatConnectionDuration,
|
formatConnectionDuration,
|
||||||
localProxyUrls,
|
localProxyUrls,
|
||||||
subscriptionDomain,
|
subscriptionDomain,
|
||||||
@@ -54,6 +55,26 @@ test('gateway proxy URLs use its network address', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('copy uses the synchronous native path available on gateway HTTP', async () => {
|
||||||
|
const textarea = {
|
||||||
|
style: {},
|
||||||
|
setAttribute() {},
|
||||||
|
select() { this.selected = true; },
|
||||||
|
remove() { this.removed = true; },
|
||||||
|
};
|
||||||
|
const documentRef = {
|
||||||
|
body: { append(node) { node.appended = true; } },
|
||||||
|
createElement: () => textarea,
|
||||||
|
execCommand: (command) => command === 'copy',
|
||||||
|
};
|
||||||
|
|
||||||
|
await copyText('192.168.50.111', { documentRef });
|
||||||
|
|
||||||
|
assert.equal(textarea.value, '192.168.50.111');
|
||||||
|
assert.equal(textarea.selected, true);
|
||||||
|
assert.equal(textarea.removed, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('subscription usage combines traffic and caps progress', () => {
|
test('subscription usage combines traffic and caps progress', () => {
|
||||||
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
|
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
|
||||||
upload: 30,
|
upload: 30,
|
||||||
|
|||||||
Reference in New Issue
Block a user