Keep verified Gateway active through transient discovery failures
This commit is contained in:
@@ -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.
|
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
|
## 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.
|
`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.
|
||||||
|
|||||||
@@ -190,10 +190,16 @@ export function applyGatewayPreference(state, enabled) {
|
|||||||
export function nextGatewayAutoState(current, {
|
export function nextGatewayAutoState(current, {
|
||||||
network,
|
network,
|
||||||
verifiedGateway = null,
|
verifiedGateway = null,
|
||||||
failureLimit = 3,
|
|
||||||
error = 'Gateway presence check failed',
|
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 routeChanged = !sameGatewayRoute(current.gateway, network);
|
||||||
const base = routeChanged
|
const base = routeChanged
|
||||||
@@ -215,11 +221,8 @@ export function nextGatewayAutoState(current, {
|
|||||||
const failures = base.failures + 1;
|
const failures = base.failures + 1;
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
mode: base.mode === 'gateway-direct' && failures < failureLimit
|
mode: base.gatewayId ? 'gateway-direct' : 'local-vpn',
|
||||||
? 'gateway-direct'
|
|
||||||
: 'local-vpn',
|
|
||||||
failures,
|
failures,
|
||||||
gatewayId: failures < failureLimit ? base.gatewayId : '',
|
|
||||||
lastError: String(error || 'Gateway presence check failed'),
|
lastError: String(error || 'Gateway presence check failed'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,10 +302,17 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!network) {
|
if (!network) {
|
||||||
const nextState = nextGatewayAutoState(gatewayAutoState, { network: null });
|
const discoveryError = 'macOS default gateway недоступен или устарел';
|
||||||
if (state.subscriptionUrl) {
|
const discoveredState = nextGatewayAutoState(gatewayAutoState, {
|
||||||
nextState.lastError = 'macOS default gateway недоступен или устарел';
|
network: null,
|
||||||
}
|
error: discoveryError,
|
||||||
|
});
|
||||||
|
const nextState = applyGatewayPreference(
|
||||||
|
state.subscriptionUrl
|
||||||
|
? { ...discoveredState, lastError: discoveryError }
|
||||||
|
: discoveredState,
|
||||||
|
state.gatewayAutoEnabled !== false,
|
||||||
|
);
|
||||||
await applyGatewayAutoState(
|
await applyGatewayAutoState(
|
||||||
nextState,
|
nextState,
|
||||||
{ reconfigure },
|
{ reconfigure },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.8.11',
|
macClient: '0.8.12',
|
||||||
gatewayClient: '0.8.10',
|
gatewayClient: '0.8.10',
|
||||||
gatewayBackend: '0.8.0',
|
gatewayBackend: '0.8.1',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ test('Gateway presence is authenticated by the shared subscription secret', asyn
|
|||||||
}), false);
|
}), 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 now = Date.now();
|
||||||
const statePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-route-')), 'network.json');
|
const statePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-route-')), 'network.json');
|
||||||
fs.writeFileSync(statePath, JSON.stringify({
|
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 });
|
state = nextGatewayAutoState(state, { network });
|
||||||
assert.equal(state.mode, 'gateway-direct');
|
assert.equal(state.mode, 'gateway-direct');
|
||||||
state = nextGatewayAutoState(state, { network });
|
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' };
|
const newNetwork = { ...network, mac: '11:22:33:44:55:66' };
|
||||||
state = nextGatewayAutoState(nextGatewayAutoState(createGatewayAutoState(), {
|
state = nextGatewayAutoState(state, { network: newNetwork });
|
||||||
network,
|
|
||||||
verifiedGateway: { gatewayId: 'gateway-1' },
|
|
||||||
}), { network: newNetwork });
|
|
||||||
assert.equal(state.mode, 'local-vpn');
|
assert.equal(state.mode, 'local-vpn');
|
||||||
|
assert.equal(state.gatewayId, '');
|
||||||
|
|
||||||
assert.equal(readHostNetworkState(statePath, { now: now + 16_000 }), null);
|
assert.equal(readHostNetworkState(statePath, { now: now + 16_000 }), null);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user