diff --git a/README.md b/README.md index 8204a41..5d50617 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/inst - UI: `http://127.0.0.1:3456` - HTTP/SOCKS proxy: `127.0.0.1:8080` +В Mac UI есть **Домашний режим**. Когда он включён, приложения по-прежнему используют `127.0.0.1:8080`, но весь proxy-трафик идёт напрямую без VPN. + Ручной запуск из checkout: ```bash diff --git a/src/server/clientSettings.js b/src/server/clientSettings.js new file mode 100644 index 0000000..3095877 --- /dev/null +++ b/src/server/clientSettings.js @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import path from "node:path"; +import { settings } from "./config.js"; + +const DEFAULT_CLIENT_SETTINGS = { + homeBypassEnabled: false, +}; + +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"); +} + +export function normalizeClientSettings(input = {}) { + return { + homeBypassEnabled: Boolean(input.homeBypassEnabled), + }; +} + +export function readClientSettings() { + return normalizeClientSettings({ + ...DEFAULT_CLIENT_SETTINGS, + ...readJson(settings.clientSettingsPath, {}), + }); +} + +export function writeClientSettings(input) { + const normalized = normalizeClientSettings({ + ...readClientSettings(), + ...(input && typeof input === "object" ? input : {}), + }); + writeJson(settings.clientSettingsPath, normalized); + return normalized; +} diff --git a/src/server/config.js b/src/server/config.js index ab0a8e3..f87d94e 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -16,6 +16,7 @@ export const settings = { statePath: path.join(dataDir, "state.json"), customRulesPath: path.join(dataDir, "custom-rules.json"), customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"), + clientSettingsPath: path.join(dataDir, "client-settings.json"), devicesPath: path.join(dataDir, "devices.json"), deviceRulesPath: path.join(dataDir, "device-rules.json"), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), diff --git a/src/server/index.js b/src/server/index.js index aa071ca..519df8d 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -17,6 +17,10 @@ import { readDeviceProfiles, writeDeviceProfiles, } from "./devices.js"; +import { + readClientSettings, + writeClientSettings, +} from "./clientSettings.js"; import { matchRoute, detectRuleConflicts } from "./routeMatcher.js"; import { tcpPing, resolveHost } from "./ping.js"; @@ -602,6 +606,7 @@ function publicState() { proxyBindIp: settings.bindIp, tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null, routingRuDirect: settings.routingRuDirect, + clientSettings: readClientSettings(), configExists: fs.existsSync(settings.configPath), singboxRunning: Boolean(singboxProcess), singboxStartedAt, @@ -924,6 +929,33 @@ async function handleApi(req, res) { }); } + if (req.method === "GET" && req.url === "/api/client-settings") { + return sendJson(res, 200, { + success: true, + clientSettings: readClientSettings(), + }); + } + + if (req.method === "PUT" && req.url === "/api/client-settings") { + const body = await readBody(req); + const clientSettings = writeClientSettings(body.clientSettings || body); + const prevState = readJson(settings.statePath, {}); + + if ( + settings.appMode === "client" && + prevState.selectedTag && + readJson(settings.subscriptionCachePath, null)?.config + ) { + await applySelectedServer(prevState.selectedTag); + } + + return sendJson(res, 200, { + success: true, + clientSettings, + singboxRunning: Boolean(singboxProcess), + }); + } + if (req.method === "GET" && req.url === "/api/rule-sets") { return sendJson(res, 200, { success: true, diff --git a/src/server/singbox.js b/src/server/singbox.js index ad6f680..8ecc5cc 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -7,6 +7,7 @@ import { normalizeCidr, readDeviceProfiles, } from "./devices.js"; +import { readClientSettings } from "./clientSettings.js"; const PROXY_TYPES = new Set([ "vless", @@ -259,7 +260,11 @@ export function buildGatewayConfig( const customRuleSets = readCustomRuleSets(); const clientMode = settings.appMode === "client"; - const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }]; + const clientSettings = clientMode ? readClientSettings() : null; + const clientOutbound = clientSettings?.homeBypassEnabled + ? "direct" + : vpnOutbound.tag; + const proxyOnlyRules = [{ inbound: [MIXED_INBOUND], outbound: clientOutbound }]; const inbounds = [ ...(clientMode ? [] diff --git a/src/web/App.jsx b/src/web/App.jsx index 22d67f9..7b08c55 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -33,6 +33,9 @@ function App() { proxyDefaultMode: 'vpn', devices: [], }); + const [clientSettings, setClientSettings] = useState({ + homeBypassEnabled: false, + }); const [selectedTag, setSelectedTag] = useState(''); const [pendingTag, setPendingTag] = useState(''); const [busy, setBusy] = useState(false); @@ -78,6 +81,7 @@ function App() { proxyDefaultMode: 'vpn', devices: data.devices || [], }); + setClientSettings(data.clientSettings || { homeBypassEnabled: false }); setSelectedTag((prev) => prev || data.selectedTag || ''); setPendingTag((prev) => prev || data.selectedTag || ''); } @@ -261,6 +265,14 @@ function App() { saveDevicesConfig(nextConfig); } + async function saveClientSettings(nextSettings) { + return withBusy(null, async () => { + const data = await api.clientSettings.save(nextSettings); + setClientSettings(data.clientSettings || { homeBypassEnabled: false }); + await loadState(); + }, { quiet: true }); + } + // === Rules CRUD === function emptyRule() { return { @@ -404,6 +416,8 @@ function App() { servers={servers} pendingTag={pendingTag} setPendingTag={setPendingTag} + clientSettings={clientSettings} + onSaveClientSettings={saveClientSettings} onFetchSubscription={fetchSubscription} onApply={applyServer} onRestart={restartSingbox} diff --git a/src/web/api.js b/src/web/api.js index 8d39055..a4342f1 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -44,6 +44,15 @@ export const api = { }), }, + clientSettings: { + get: () => request("/api/client-settings"), + save: (clientSettings) => + request("/api/client-settings", { + method: "PUT", + body: JSON.stringify({ clientSettings }), + }), + }, + ruleSets: { get: () => request("/api/rule-sets"), save: (ruleSets) => diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index dc7a40f..d456a54 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -25,6 +25,7 @@ function CopyField({ label, value }) { } function ClientHero({ state, status, activeServer }) { + const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled); const cfg = { running: { title: 'Локальный proxy работает', @@ -57,6 +58,15 @@ function ClientHero({ state, status, activeServer }) { kind: 'neutral', }, }[status] || {}; + const view = homeBypass + ? { + ...cfg, + title: 'Домашний режим: VPN выключен', + hint: 'Локальный proxy работает напрямую', + badge: 'Напрямую', + kind: 'info', + } + : cfg; const userInfo = state?.userInfo; const traffic = userInfo @@ -66,14 +76,14 @@ function ClientHero({ state, status, activeServer }) { return (
- {cfg.badge} -

{cfg.title}

-

{cfg.hint}

+ {view.badge} +

{view.title}

+

{view.hint}

Активный сервер - {activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'} + {homeBypass ? 'Не используется дома' : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}
Трафик @@ -101,6 +111,7 @@ function ClientSetup({ }) { const selected = pendingTag || state?.selectedTag || ''; const canApply = selected && selected !== state?.selectedTag; + const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled); return (
@@ -146,7 +157,9 @@ function ClientSetup({
- В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN. + {homeBypass + ? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.' + : 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
@@ -174,12 +187,43 @@ function ProxyCard({ state }) { ); } +function HomeBypassCard({ settings, busy, onSave }) { + const enabled = Boolean(settings?.homeBypassEnabled); + + return ( +
+
+

Домашний режим

+ + {enabled ? 'Напрямую' : 'Через VPN'} + +
+

+ Включайте дома: приложения продолжают использовать 127.0.0.1:8080, но VPN не используется. +

+ +
+ ); +} + function ClientFlow({ state, activeServer }) { + const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled); const steps = [ { label: 'Mac', value: 'приложения' }, { label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` }, - { label: 'VPN-сервер', value: activeServer?.tag || state?.selectedTag || 'не выбран' }, - { label: 'Интернет', value: state?.singboxRunning ? 'через VPN' : 'ожидает' }, + { label: homeBypass ? 'Домашняя сеть' : 'VPN-сервер', value: homeBypass ? 'напрямую' : activeServer?.tag || state?.selectedTag || 'не выбран' }, + { label: 'Интернет', value: state?.singboxRunning ? homeBypass ? 'без VPN' : 'через VPN' : 'ожидает' }, ]; return ( @@ -222,6 +266,8 @@ export function ClientOverviewPage({ servers, pendingTag, setPendingTag, + clientSettings, + onSaveClientSettings, onFetchSubscription, onApply, onRestart, @@ -243,6 +289,9 @@ export function ClientOverviewPage({ />
+ +
+
{ { inbound: ["mixed-in"], outbound: "test-vpn" }, ]); }); + +test("client home bypass routes the local proxy directly", () => { + fs.writeFileSync( + path.join(process.env.DATA_DIR, "client-settings.json"), + JSON.stringify({ homeBypassEnabled: true }), + ); + + const config = buildGatewayConfig(subscriptionConfig, "test-vpn"); + + assert.deepEqual(config.route.rule_set, []); + assert.deepEqual(config.route.rules, [ + { inbound: ["mixed-in"], outbound: "direct" }, + ]); +});