Files
wangjia f035552ff5 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>
2026-07-02 13:29:01 +08:00

148 lines
4.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// connect_api.dart — 控制面 connect 接口客户端
//
// 职责:POST /v1/nodes/:id/connect → 获取完整 sing-box config JSON。
// 关键约束(§3.1):返回的 JSON 字符串必须原样透传给 VpnBridge.start
// 调用方禁止对字符串做任何增删改;本类同样不修改响应体。
//
// M6 联调:mock 服务器地址由 --dart-define=PANGOLIN_API_URL 注入(默认 :8081)。
// tsk_nuoKSM4Vt-zK
import 'dart:convert';
import 'package:http/http.dart' as http;
/// 控制面接口调用失败时抛出。
/// [statusCode] < 0 表示网络层错误,-2 表示响应体 JSON 解析失败。
class ConnectApiException implements Exception {
const ConnectApiException({
required this.statusCode,
required this.messageZh,
required this.messageEn,
this.code,
this.maxDevices,
});
final int statusCode;
final String messageZh;
final String messageEn;
/// 服务端错误码(如 DEVICE_LIMIT_EXCEEDED);网络/解析错误时为 null。
final String? code;
/// DEVICE_LIMIT_EXCEEDED 时服务端下发的套餐设备上限。
final int? maxDevices;
@override
String toString() => 'ConnectApiException($statusCode${code != null ? '/$code' : ''}): $messageZh';
}
/// [ConnectApi] 封装 POST /v1/nodes/:id/connect 调用。
///
/// 可通过 [client] 注入自定义 [http.Client](方便单测使用 MockClient)。
class ConnectApi {
ConnectApi({
required this.baseUrl,
required this.authToken,
http.Client? client,
}) : _client = client ?? http.Client();
final String baseUrl;
final String authToken;
final http.Client _client;
/// 向控制面请求 [nodeId] 节点的完整 sing-box config JSON。
///
/// 返回**原始响应体字符串**,调用方必须不做任何修改直接传入 `VpnBridge.start`。
/// 异常:[ConnectApiException]HTTP 非 200、超时、非法 JSON)。
Future<String> fetchConfig({
required String nodeId,
required String deviceId,
bool splitCN = false,
}) async {
// splitCN=true 时带 ?split_cn=1:控制面渲染国内 IP/域名直连(不走隧道,#5)。
var uri = Uri.parse('$baseUrl/v1/nodes/$nodeId/connect');
if (splitCN) {
uri = uri.replace(queryParameters: {'split_cn': '1'});
}
final http.Response response;
try {
response = await _client
.post(
uri,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $authToken',
},
body: jsonEncode({'device_id': deviceId}),
)
.timeout(const Duration(seconds: 15));
} on Exception catch (e) {
throw ConnectApiException(
statusCode: -1,
messageZh: '网络请求失败,请检查连接后重试',
messageEn: 'Network error: $e',
);
}
if (response.statusCode != 200) {
// 解析结构化错误体(code/message/max_devices),失败则回退通用文案。
String? code;
int? maxDevices;
String? zh, en;
try {
final m = jsonDecode(response.body);
if (m is Map) {
code = m['code'] as String?;
maxDevices = (m['max_devices'] as num?)?.toInt();
zh = m['message_zh'] as String?;
en = m['message_en'] as String?;
}
} catch (_) {/* 非 JSON,用通用文案 */}
throw ConnectApiException(
statusCode: response.statusCode,
code: code,
maxDevices: maxDevices,
messageZh: zh ?? '节点连接失败 (HTTP ${response.statusCode})',
messageEn: en ?? 'Connect failed (HTTP ${response.statusCode})',
);
}
// 仅做合法性校验,不修改响应体。
try {
jsonDecode(response.body);
} catch (_) {
throw const ConnectApiException(
statusCode: -2,
messageZh: '收到的配置格式无效,请稍后重试',
messageEn: 'Invalid config JSON received from server',
);
}
// 原样返回——调用方透传给 VpnBridge.start,不得在途中修改。
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();
}