From 62e968f133898233653d9b1b8b1d3ad5576b05ad Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 17 Oct 2026 21:17:17 +0300 Subject: [PATCH] fix(app): stop probing the control plane while disconnected VpnConnection._init() started a 30 s /health poll from the constructor with no lifecycle gating, so it ran for the life of the app process - backgrounded, and whether or not the user ever connected. The app is excluded from its own tunnel, so those requests left in the clear. A periodic request that fires while the user is deliberately not connected is an avoidable fingerprint, and rule 60 already ruled probes out: reachability is meant to be passive. All it bought was the status dot before the first connect, and it got that wrong in both directions - "Offline" on a transient resolver failure against a server that was fine. Reachability now comes from a session sync that succeeded. serverHealthy is isConnected && hasSyncedOnce, so a green dot means a working session rather than a check made beforehand; while disconnected the bar reads "Ready" and claims nothing either way. checkHealth() goes with it. Nothing calls it, and leaving a probe helper in place invites its reuse. Pinned in test/session_lifecycle_regression_test.dart so it cannot come back as a tidy-up. flutter analyze clean, 10/10 tests pass. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 +++++++++++++ lib/components/status_bar.dart | 13 ++++++------- lib/logic/hivemind_service.dart | 12 ------------ lib/logic/vpn_connection.dart | 18 ------------------ test/session_lifecycle_regression_test.dart | 20 ++++++++++++++++++++ 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0daf3a5..024cb18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH --- +## [Unreleased] + +### Removed +- **The background server check while disconnected.** The app no longer contacts + the server every 30 seconds while you are not connected to it. Nothing about + the connected session changes. + +### Changed +- **The status bar no longer guesses whether the server is up.** It reads + "Ready" while disconnected and shows "Online" once a session has synced, so a + green dot now means a session that is actually working rather than a + reachability check made beforehand. + ## [3.3.8] — 2026-10-17 ### Changed diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart index 9727151..7dd211a 100644 --- a/lib/components/status_bar.dart +++ b/lib/components/status_bar.dart @@ -49,15 +49,14 @@ class StatusBar extends StatelessWidget { builder: (context, vpn, timer, _) { final isConnected = vpn.status == VpnStatus.connected; final isConnecting = vpn.status == VpnStatus.connecting; - final serverHealthy = - vpn.serverReachable || (isConnected && timer.hasSyncedOnce); - final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable; + // Rule 60: reachability is known only from a session sync that + // succeeded, never from a probe. While disconnected the app knows + // nothing about the server, so it claims nothing either way. + final serverHealthy = isConnected && timer.hasSyncedOnce; final statusLabel = isConnected - ? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…') - : (isConnecting - ? 'Connecting…' - : (vpn.serverReachable ? 'Ready' : 'Offline')); + ? (timer.hasSyncedOnce ? 'Online' : 'Syncing…') + : (isConnecting ? 'Connecting…' : 'Ready'); final detail = _detail(vpn, timer); diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index bf62bca..d7e9543 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -197,18 +197,6 @@ class HivemindService { throw Exception('Session not activated. Server callback may have timed out.'); } - static Future checkHealth() async { - try { - final response = await controlGet( - _publicUrl('/health'), - timeout: const Duration(seconds: 3), - ); - return response.statusCode == 200; - } catch (_) { - return false; - } - } - static String newNonce() { final random = Random.secure(); final high = random.nextInt(0x7FFFFFFF); diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 00ebe4d..f7168a2 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -38,9 +38,6 @@ class VpnConnection extends ChangeNotifier { bool _adoptedRunningRuntime = false; bool get adoptedRunningRuntime => _adoptedRunningRuntime; - bool _serverReachable = false; - bool get serverReachable => _serverReachable; - ConnectionMode _activeMode = ConnectionMode.tun; ConnectionMode get activeMode => _activeMode; @@ -73,7 +70,6 @@ class VpnConnection extends ChangeNotifier { ); } - Timer? _healthTimer; StreamSubscription? _networkSubscription; SecureSocksSession? _lastSecureSocks; @@ -142,11 +138,6 @@ class VpnConnection extends ChangeNotifier { }, ); - unawaited(_checkHealth()); - _healthTimer = Timer.periodic(const Duration(seconds: 30), (_) { - unawaited(_checkHealth()); - }); - try { final coreVersion = await _vless.getCoreVersion(); if (!_disposed) { @@ -472,14 +463,6 @@ class VpnConnection extends ChangeNotifier { notifyListeners(); } - Future _checkHealth() async { - if (_disposed || _status == VpnStatus.connected) return; - final reachable = await HivemindService.checkHealth(); - if (_disposed || _status == VpnStatus.connected) return; - _serverReachable = reachable; - notifyListeners(); - } - @override void dispose() { _disposed = true; @@ -487,7 +470,6 @@ class VpnConnection extends ChangeNotifier { _suppressNativeConnect = true; _userDisconnecting = true; HivemindService.cancel(); - _healthTimer?.cancel(); _networkSubscription?.cancel(); // Provider/UI disposal is not a user disconnect command. Keeping teardown // in disconnect() prevents lifecycle churn from silently dropping the VPN. diff --git a/test/session_lifecycle_regression_test.dart b/test/session_lifecycle_regression_test.dart index 6f84e29..5012fdc 100644 --- a/test/session_lifecycle_regression_test.dart +++ b/test/session_lifecycle_regression_test.dart @@ -164,4 +164,24 @@ void main() { reason: 'the nonce check must not be skippable', ); }); + + test('nothing probes the control plane while disconnected - rule 60', () { + final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync(); + final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync(); + final bar = File('lib/components/status_bar.dart').readAsStringSync(); + + // The runtime excludes the app from its own tunnel + // (XrayVPNService.kt addDisallowedApplication), so a periodic /health probe + // was a cleartext-SNI metronome to the API host - which resolves to the + // tunnel IP. It fed step 1 of the kill chain in KNOWN_SECURITY_ISSUES #3, + // in exchange for a green dot before the user had connected to anything. + expect(vpn, isNot(contains('_healthTimer'))); + expect(vpn, isNot(contains('_checkHealth'))); + expect(hivemind, isNot(contains('checkHealth'))); + expect(hivemind, isNot(contains("'/health'"))); + + // Reachability is passive now: a session sync that succeeded, nothing else. + expect(bar, isNot(contains('serverReachable'))); + expect(bar, contains('isConnected && timer.hasSyncedOnce')); + }); }