From 0ab912c64c2be2b85b4186eefa7ccc7fadb2e175 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 15:17:07 +0300 Subject: [PATCH] Add gateway auto toggle for client mode --- src/server/gatewayPresence.js | 7 ++ src/server/index.js | 34 ++++++++- src/web/App.jsx | 1 + src/web/api.js | 6 ++ src/web/components/ClientOverviewPage.jsx | 57 +++++++++++--- src/web/styles.css | 91 +++++++++++++++++++++++ test/server/gateway-presence.test.js | 15 ++++ 7 files changed, 197 insertions(+), 14 deletions(-) diff --git a/src/server/gatewayPresence.js b/src/server/gatewayPresence.js index ded8ddd..0c5a957 100644 --- a/src/server/gatewayPresence.js +++ b/src/server/gatewayPresence.js @@ -174,6 +174,13 @@ export function createGatewayAutoState() { }; } +export function applyGatewayPreference(state, enabled) { + return { + ...state, + mode: enabled && state.gatewayId ? 'gateway-direct' : 'local-vpn', + }; +} + export function nextGatewayAutoState(current, { network, verifiedGateway = null, diff --git a/src/server/index.js b/src/server/index.js index a14315e..09a4252 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -5,6 +5,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { isDeepStrictEqual } from 'node:util'; import { settings } from './config.js'; import { + applyGatewayPreference, buildGatewayPresence, createGatewayAutoState, nextGatewayAutoState, @@ -183,6 +184,7 @@ async function startSingbox() { function publicState() { const state = readJson(settings.statePath, {}); + const gatewayAutoEnabled = state.gatewayAutoEnabled !== false; return { mode: settings.appMode, port: settings.port, @@ -197,6 +199,8 @@ function publicState() { fetchedAt: state.fetchedAt || null, gatewayAuto: settings.appMode === 'client' ? { mode: gatewayAutoState.mode, + enabled: gatewayAutoEnabled, + available: Boolean(gatewayAutoState.gatewayId), address: gatewayAutoState.gateway?.gateway || '', interface: gatewayAutoState.gateway?.interface || '', failures: gatewayAutoState.failures, @@ -291,7 +295,10 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) { return gatewayAutoState; } await applyGatewayAutoState( - nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }), + applyGatewayPreference( + nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }), + latestState.gatewayAutoEnabled !== false, + ), { reconfigure }, ); } catch (error) { @@ -311,7 +318,10 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) { console.warn(`[control] Gateway не используется: ${reason}`); } await applyGatewayAutoState( - nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }), + applyGatewayPreference( + nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }), + latestState.gatewayAutoEnabled !== false, + ), { reconfigure }, ); } @@ -458,8 +468,10 @@ async function handleApi(req, res) { const parsed = await fetchSubscription(normalizedUrl); await serializeControl(async () => { writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed }); + const previousState = readJson(settings.statePath, {}); writeJson(settings.statePath, { subscriptionUrl: normalizedUrl, + gatewayAutoEnabled: previousState.gatewayAutoEnabled !== false, servers: parsed.servers, userInfo: parsed.userInfo, fetchedAt: parsed.fetchedAt, @@ -481,6 +493,24 @@ async function handleApi(req, res) { return sendJson(res, 200, await refreshSavedSubscription()); } + if (req.method === 'POST' && req.url === '/api/gateway-auto') { + if (settings.appMode !== 'client') { + return sendJson(res, 400, { success: false, error: 'Режим доступен только в Harbor Connect' }); + } + const { enabled } = await readBody(req); + if (typeof enabled !== 'boolean') { + return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' }); + } + await serializeControl(async () => { + writeJson(settings.statePath, { + ...readJson(settings.statePath, {}), + gatewayAutoEnabled: enabled, + }); + await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled)); + }); + return sendJson(res, 200, { success: true, gatewayAuto: publicState().gatewayAuto }); + } + if (req.method === 'DELETE' && req.url === '/api/subscription') { await serializeControl(async () => { await stopSingbox(); diff --git a/src/web/App.jsx b/src/web/App.jsx index 0878343..2d330ec 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -101,6 +101,7 @@ function App() { onApply={(tag) => run(() => api.apply(tag))} onRestart={() => run(api.singbox.restart)} onStop={() => run(api.singbox.stop)} + onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled))} /> diff --git a/src/web/api.js b/src/web/api.js index 5161aff..8ef63ce 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -32,6 +32,12 @@ export const api = { method: 'POST', body: JSON.stringify({ selectedTag }), }), + gatewayAuto: { + setEnabled: (enabled) => request('/api/gateway-auto', { + method: 'POST', + body: JSON.stringify({ enabled }), + }), + }, singbox: { stop: () => request('/api/singbox/stop', { method: 'POST' }), restart: () => request('/api/singbox/restart', { method: 'POST' }), diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index ddf7fdb..1221b7a 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -73,20 +73,45 @@ function DurationPart({ name, children }) { ); } -function HarborBrand({ isGateway }) { +function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGatewayAuto }) { const product = isGateway ? 'Gateway' : 'Connect'; + const switchable = !isGateway && gatewayAvailable; + const label = gatewayDirect + ? 'Игнорировать Harbor Gateway и использовать локальный VPN' + : 'Использовать обнаруженный Harbor Gateway'; + + const content =
+ + + Harbor + {switchable ? : {product}} + + {switchable && } +
; return ( -
-
- - Harbor {product} -
+
+ {switchable ? :
{content}
}
); } @@ -106,9 +131,11 @@ export function ClientOverviewPage({ onApply, onRestart, onStop, + onSetGatewayAuto, }) { const isGateway = state?.mode === 'gateway'; const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct'; + const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available); const connected = Boolean(state?.singboxRunning); const hasSubscription = Boolean(state?.hasSubscription); const selectedTag = pendingTag || state?.selectedTag || ''; @@ -387,7 +414,13 @@ export function ClientOverviewPage({ return (
- + {hasSubscription && subscriptionContentReady &&