diff --git a/docs/product/application-state.md b/docs/product/application-state.md index b2c4c1e..24192c4 100644 --- a/docs/product/application-state.md +++ b/docs/product/application-state.md @@ -60,6 +60,8 @@ The frontend keeps the accepted snapshot in one reducer and replaces it only whe Browser transport state lives beside, not inside, the domain snapshot. It records boot status, last successful sync time and consecutive failures. Three failed polls mark the retained snapshot stale; the next successful GET or mutation clears that marker. An initial failure shows `control-unreachable`, `incompatible-api` or `fatal` without inventing domain state. +Gateway discovery follows the same retain-and-mark-stale rule. Once a concrete default Gateway has been verified, transient presence failures or a briefly stale macOS route snapshot keep `gateway-direct` active and report `route.reason = gateway-stale`; they do not restart sing-box into `local-vpn`. Local routing resumes only after the user disables Gateway mode or macOS reports a different default Gateway identity. + ## Desired and applied state `selection.desiredServerId` records the user's requested server. `selection.appliedServerId` changes only after its sing-box configuration has been applied. Likewise, `connection.desired` records intent while `connection.process` reports the observed runtime. A failed operation can therefore leave desired and applied values different without pretending that the request succeeded. diff --git a/src/server/gatewayPresence.js b/src/server/gatewayPresence.js index 4429380..3d1995a 100644 --- a/src/server/gatewayPresence.js +++ b/src/server/gatewayPresence.js @@ -190,10 +190,16 @@ export function applyGatewayPreference(state, enabled) { export function nextGatewayAutoState(current, { network, verifiedGateway = null, - failureLimit = 3, error = 'Gateway presence check failed', }) { - if (!network) return createGatewayAutoState(); + if (!network) { + if (!current.gatewayId) return createGatewayAutoState(); + return { + ...current, + failures: current.failures + 1, + lastError: String(error || 'Gateway presence check failed'), + }; + } const routeChanged = !sameGatewayRoute(current.gateway, network); const base = routeChanged @@ -215,11 +221,8 @@ export function nextGatewayAutoState(current, { const failures = base.failures + 1; return { ...base, - mode: base.mode === 'gateway-direct' && failures < failureLimit - ? 'gateway-direct' - : 'local-vpn', + mode: base.gatewayId ? 'gateway-direct' : 'local-vpn', failures, - gatewayId: failures < failureLimit ? base.gatewayId : '', lastError: String(error || 'Gateway presence check failed'), }; } diff --git a/src/server/index.js b/src/server/index.js index 4919b15..ed6e0ee 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -302,10 +302,17 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) { : null; if (!network) { - const nextState = nextGatewayAutoState(gatewayAutoState, { network: null }); - if (state.subscriptionUrl) { - nextState.lastError = 'macOS default gateway недоступен или устарел'; - } + const discoveryError = 'macOS default gateway недоступен или устарел'; + const discoveredState = nextGatewayAutoState(gatewayAutoState, { + network: null, + error: discoveryError, + }); + const nextState = applyGatewayPreference( + state.subscriptionUrl + ? { ...discoveredState, lastError: discoveryError } + : discoveredState, + state.gatewayAutoEnabled !== false, + ); await applyGatewayAutoState( nextState, { reconfigure }, diff --git a/src/shared/versions.js b/src/shared/versions.js index 331e761..83beeb6 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.8.11', + macClient: '0.8.12', gatewayClient: '0.8.10', - gatewayBackend: '0.8.0', + gatewayBackend: '0.8.1', }); export function parseVersion(value) { diff --git a/test/server/gateway-presence.test.js b/test/server/gateway-presence.test.js index 1675bda..7998d8c 100644 --- a/test/server/gateway-presence.test.js +++ b/test/server/gateway-presence.test.js @@ -78,7 +78,7 @@ test('Gateway presence is authenticated by the shared subscription secret', asyn }), false); }); -test('host route freshness and Gateway failures drive a safe automatic fallback', () => { +test('verified Gateway stays active through transient discovery failures', () => { const now = Date.now(); const statePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-route-')), 'network.json'); fs.writeFileSync(statePath, JSON.stringify({ @@ -106,14 +106,23 @@ test('host route freshness and Gateway failures drive a safe automatic fallback' state = nextGatewayAutoState(state, { network }); assert.equal(state.mode, 'gateway-direct'); state = nextGatewayAutoState(state, { network }); - assert.equal(state.mode, 'local-vpn'); + assert.equal(state.mode, 'gateway-direct'); + assert.equal(state.gatewayId, 'gateway-1'); + assert.equal(state.failures, 3); + + state = nextGatewayAutoState(state, { + network: null, + error: 'host snapshot stale', + }); + assert.equal(state.mode, 'gateway-direct'); + assert.equal(state.gatewayId, 'gateway-1'); + assert.equal(state.lastError, 'host snapshot stale'); + assert.equal(applyGatewayPreference(state, false).mode, 'local-vpn'); const newNetwork = { ...network, mac: '11:22:33:44:55:66' }; - state = nextGatewayAutoState(nextGatewayAutoState(createGatewayAutoState(), { - network, - verifiedGateway: { gatewayId: 'gateway-1' }, - }), { network: newNetwork }); + state = nextGatewayAutoState(state, { network: newNetwork }); assert.equal(state.mode, 'local-vpn'); + assert.equal(state.gatewayId, ''); assert.equal(readHostNetworkState(statePath, { now: now + 16_000 }), null);