fix: disconnect 吊销每设备凭证 + 客户端断开时真正调用(F4,#28)

审查 F4 两层问题:①DisconnectNode 只撤账户级 ent.DpUUID,而 connect 下发的是
每设备 devDp——吊销从未对准目标;②客户端从未调用过 disconnect 端点(只有
fetchConfig),端点是死代码,凭证一律活到 TTL(付费 24h)。

- server:disconnect 收 optional body {device_id},吊销该设备 dp_uuid(优先)+
  账户级遗留兜底;旧客户端无 body 走兜底,行为不回归。nil hub 守卫(测试友好,
  与 ListNodes 一致)。
- client:ConnectApi.disconnect(best-effort,5s 超时吞错);_disconnect 加
  revokeCredential 参数,仅在「不会紧接重连同节点」的路径置 true(用户主动断开/
  额度耗尽/登出)——看门狗断开→重连若也吊销,revoke 可能晚于新 connect 推送、
  误杀新会话。
- test:httpapi disconnect 三态(带 device_id 双吊销/无 body 仅兜底/设备已移除
  不炸);client 功能套件 182 过(golden 为已知 macOS 本地漂移,不相关)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-02 13:29:01 +08:00
parent 63d1baeb01
commit f035552ff5
4 changed files with 196 additions and 11 deletions
+22
View File
@@ -121,5 +121,27 @@ class ConnectApi {
return response.body;
}
/// 通知控制面吊销本设备在 [nodeId] 上的数据面凭证(F4)。
///
/// best-effort:断开的本地拆隧道不依赖它,任何失败(网络/401/超时)都吞掉——
/// 凭证最迟到 TTL 也会过期,这里只是让「断开」在服务端即刻生效。
Future<void> disconnect({
required String nodeId,
required String deviceId,
}) async {
try {
await _client
.post(
Uri.parse('$baseUrl/v1/nodes/$nodeId/disconnect'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $authToken',
},
body: jsonEncode({'device_id': deviceId}),
)
.timeout(const Duration(seconds: 5));
} catch (_) {/* best-effort */}
}
void dispose() => _client.close();
}
+21 -4
View File
@@ -110,7 +110,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_authSub = _ref.listen<AuthState>(authProvider, (prev, next) {
if ((prev?.isLoggedIn ?? false) && !next.isLoggedIn) {
_userDisconnect = true; // 视为「非节点异常」的主动断开,不弹「节点异常」
unawaited(_disconnect());
// 登出也尝试吊销凭证(F4);token 可能已失效,best-effort 吞错。
unawaited(_disconnect(revokeCredential: true));
}
});
// 生命周期闸:切后台停看门狗,回前台再开。原因:后台(尤其 Android Doze)会把 urltest
@@ -175,7 +176,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_connect();
case VpnPhase.on:
_userDisconnect = true; // 用户主动断开:其 kernel off 不当作节点异常
_disconnect();
_disconnect(revokeCredential: true); // 服务端同步吊销本设备凭证(F4)
case VpnPhase.connecting:
break; // 握手进行中,不响应
}
@@ -277,7 +278,23 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
}
Future<void> _disconnect() async {
/// [revokeCredential]:同时通知控制面吊销本设备在该节点的数据面凭证(F4)。
/// 仅在「不会紧接着重连同一节点」的路径置 true(用户主动断开/额度耗尽/登出)——
/// 看门狗「断开→立刻重连」若也吊销,revoke 可能在新 connect 推完凭证后才到达、
/// 把新会话杀掉。fire-and-forget:不阻塞本地拆隧道与 UI 回 off。
Future<void> _disconnect({bool revokeCredential = false}) async {
if (revokeCredential) {
final api = _api;
final node = _connectedNode;
if (api != null && node != null && node.uuid.isNotEmpty) {
unawaited(() async {
try {
final deviceId = await _ref.read(deviceIdentityProvider).deviceId();
await api.disconnect(nodeId: node.uuid, deviceId: deviceId);
} catch (_) {/* best-effort */}
}());
}
}
_stopElapsed();
_stopWatchdog();
try {
@@ -505,7 +522,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_offNotice = _ref.read(appTextProvider).quotaExhaustedNotice;
_ref.read(quotaProvider.notifier).markExhausted();
logLine('Quota', 'free daily minutes used up → auto disconnect');
await _disconnect();
await _disconnect(revokeCredential: true); // 额度耗尽:服务端即刻吊销(F4)
}
void _stopElapsed() {