feat(session): hand the session back when the user disconnects
The client never called /session/stop - the string appears nowhere in lib/ or local_packages/, on this branch or on main - while the server has implemented the route all along (hivemind/api.py:237) and AGENTS.md rule 33 documents its ordering. Across a full device test session, two user disconnects and several expiries, the control plane logged zero calls. So a disconnect left the VLESS client provisioned in the Reality inbound and the session dict in memory and sessions.json until its window ran out. Anyone holding that UUID could keep tunnelling for the remainder. Users were not locked out of their quota - _handle_main replaces the session with a fresh full-length one on the next connect - so this is hygiene rather than a user-facing bug. Only SessionTimer.disconnect() calls it, which is reached only from the connect button. Expiry, the hard cap and a server-ended session are already gone server-side, and the route takes nothing but a device_id as proof of ownership, so no error path may reach it. Unawaited and capped at 3 s, so an unreachable API cannot delay the teardown, and every failure is swallowed: the session expires on its own regardless. Verified against a mock control plane: user disconnect POST /api/session/stop -> session removed expiry no call; alert still posted Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
00b84dde61
commit
e67f0b93ee
2 files changed
+69
No files matched your search
@@ -76,6 +76,71 @@ class HivemindService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<http.Response> controlPost(
|
||||
Uri uri,
|
||||
Map<String, dynamic> body, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final client = http.Client();
|
||||
final request = http.Request('POST', uri)
|
||||
..headers['User-Agent'] = _ua
|
||||
..headers['Content-Type'] = 'application/json'
|
||||
..body = jsonEncode(body)
|
||||
..followRedirects = false;
|
||||
|
||||
Future<http.Response> send() async {
|
||||
final streamed = await client.send(request);
|
||||
final declaredLength = streamed.contentLength;
|
||||
if (declaredLength != null && declaredLength > _maxControlResponseBytes) {
|
||||
throw const FormatException('Control response is too large.');
|
||||
}
|
||||
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in streamed.stream) {
|
||||
if (bytes.length + chunk.length > _maxControlResponseBytes) {
|
||||
throw const FormatException('Control response is too large.');
|
||||
}
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
|
||||
return http.Response.bytes(bytes, streamed.statusCode,
|
||||
headers: streamed.headers, request: request);
|
||||
}
|
||||
|
||||
try {
|
||||
return await send().timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
client.close();
|
||||
throw TimeoutException('Control request timed out.', timeout);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Hands the session back so the server can drop the VLESS client instead of
|
||||
// leaving it provisioned until the window runs out.
|
||||
//
|
||||
// Only the user-initiated disconnect calls this. An expiry, a hard cap or a
|
||||
// server-ended session is already gone server-side, and /session/stop takes
|
||||
// nothing but a device_id as proof (hivemind/api.py notes this), so a
|
||||
// transient client error must never be allowed to destroy a live session.
|
||||
// Best effort throughout: the session expires on its own regardless.
|
||||
static Future<void> releaseSession() async {
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
await controlPost(
|
||||
_publicUrl('/session/stop'),
|
||||
{'device_id': deviceId},
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
} catch (_) {
|
||||
// Nothing to recover: the server expires the session anyway.
|
||||
}
|
||||
}
|
||||
|
||||
static void cancel() {
|
||||
_currentCallId++;
|
||||
_expectedNonce = null;
|
||||
|
||||
@@ -241,6 +241,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
Future<void> disconnect({String reason = 'User requested'}) async {
|
||||
// Only here, not in _doDisconnect: this is the one path the user actually
|
||||
// asked for. Unawaited so a slow or unreachable API never holds up the
|
||||
// teardown.
|
||||
unawaited(HivemindService.releaseSession());
|
||||
await _doDisconnect(reason);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user