diff --git a/.env.example b/.env.example index e5c012a..b67accc 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,7 @@ TPROXY_PORT=7895 TPROXY_MARK=1 TPROXY_TABLE=100 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 diff --git a/Dockerfile b/Dockerfile index 6ef34f3..e1811a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,12 @@ COPY dist /app/dist RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \ 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/*; \ else \ command -v dumb-init >/dev/null \ && command -v node >/dev/null \ - && command -v iptables >/dev/null \ - && command -v ipset >/dev/null; \ + && command -v iptables >/dev/null; \ fi RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \ @@ -45,8 +44,6 @@ ENV PORT=3456 \ PROXY_PORT=8080 \ PROXY_BIND_IP=0.0.0.0 \ TPROXY_PORT=7895 \ - DIRECT_BYPASS_CACHE=false \ - RULE_SET_DOWNLOAD_DETOUR=vpn \ DATA_DIR=/var/lib/vpn-proxy \ SING_BOX_CONFIG=/etc/sing-box/config.json \ SING_BOX_CACHE=/var/lib/sing-box/cache.db diff --git a/README.md b/README.md index 9e84162..14fc8c5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Один компактный VPN-клиент в двух режимах: -- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или HTTP/SOCKS5 Proxy; +- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или Local HTTP/SOCKS5 Proxy; - `client` — локальный proxy-клиент для macOS. В обоих режимах пользователь добавляет подписку, выбирает сервер и включает VPN на одном экране. @@ -19,9 +19,9 @@ docker compose -f docker-compose.gateway.yml up -d --build После подключения экран показывает: - `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`. diff --git a/entrypoint.sh b/entrypoint.sh index 30ade8c..0789fc5 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,6 +5,9 @@ TPROXY_PORT="${TPROXY_PORT:-7895}" TPROXY_MARK="${TPROXY_MARK:-1}" TPROXY_TABLE="${TPROXY_TABLE:-100}" 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_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}" 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 } +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() { if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then 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" } +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() { log "setup tproxy on port ${TPROXY_PORT}" 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 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_proxy_firewall @@ -90,6 +118,7 @@ shutdown() { wait "$APP_PID" 2>/dev/null || true cleanup_proxy_firewall cleanup_tproxy + cleanup_gateway_forwarding } trap 'shutdown; exit 0' SIGTERM SIGINT @@ -97,4 +126,5 @@ wait "$APP_PID" STATUS=$? cleanup_proxy_firewall cleanup_tproxy +cleanup_gateway_forwarding exit "$STATUS" diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index abf7a4d..5449754 100644 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -47,11 +47,9 @@ TPROXY_PORT=7895 TPROXY_MARK=1 TPROXY_TABLE=100 TPROXY_CHAIN=VPN_PROXY_TPROXY -TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_SRC_BYPASS -TPROXY_SOURCE_FORWARD_CHAIN=VPN_PROXY_FWD_BYPASS -TPROXY_SOURCE_NAT_CHAIN=VPN_PROXY_NAT_BYPASS -TPROXY_BYPASS_SOURCE_CIDRS= -ROUTING_RU_DIRECT=true +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 EOF echo "Created default .env. Edit ${DEPLOY_PATH}/.env if this server needs different ports." diff --git a/src/server/config.js b/src/server/config.js index e648d3c..ffe49fd 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -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", diff --git a/src/server/gatewayRouting.js b/src/server/gatewayRouting.js new file mode 100644 index 0000000..5568c35 --- /dev/null +++ b/src/server/gatewayRouting.js @@ -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()); + } +} diff --git a/src/server/index.js b/src/server/index.js index 1d1dcc7..deb7cf3 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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; diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 5c1a4bf..6d9d49f 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -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({