Keep verified Gateway active through transient discovery failures
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-31 10:57:00 +03:00
parent c6d3fd39fb
commit fdc6f687f3
5 changed files with 39 additions and 18 deletions

View File

@@ -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.

View File

@@ -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'),
};
}

View File

@@ -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 },

View File

@@ -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) {

View File

@@ -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);