From f035552ff5c5ca0526055c0b83efd7d8cbfe8aea Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 2 Jul 2026 13:29:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20disconnect=20=E5=90=8A=E9=94=80=E6=AF=8F?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=87=AD=E8=AF=81=20+=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=96=AD=E5=BC=80=E6=97=B6=E7=9C=9F=E6=AD=A3=E8=B0=83?= =?UTF-8?q?=E7=94=A8(F4,#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查 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 --- client/lib/services/connect_api.dart | 22 ++++ client/lib/state/connection_provider.dart | 25 +++- server/internal/httpapi/nodes.go | 47 ++++++-- .../internal/httpapi/nodes_disconnect_test.go | 113 ++++++++++++++++++ 4 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 server/internal/httpapi/nodes_disconnect_test.go diff --git a/client/lib/services/connect_api.dart b/client/lib/services/connect_api.dart index 1799bd7..440cdea 100644 --- a/client/lib/services/connect_api.dart +++ b/client/lib/services/connect_api.dart @@ -121,5 +121,27 @@ class ConnectApi { return response.body; } + /// 通知控制面吊销本设备在 [nodeId] 上的数据面凭证(F4)。 + /// + /// best-effort:断开的本地拆隧道不依赖它,任何失败(网络/401/超时)都吞掉—— + /// 凭证最迟到 TTL 也会过期,这里只是让「断开」在服务端即刻生效。 + Future 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(); } diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index b9f94a2..ff08086 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -110,7 +110,8 @@ class ConnectionController extends StateNotifier { _authSub = _ref.listen(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 { _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 { } } - Future _disconnect() async { + /// [revokeCredential]:同时通知控制面吊销本设备在该节点的数据面凭证(F4)。 + /// 仅在「不会紧接着重连同一节点」的路径置 true(用户主动断开/额度耗尽/登出)—— + /// 看门狗「断开→立刻重连」若也吊销,revoke 可能在新 connect 推完凭证后才到达、 + /// 把新会话杀掉。fire-and-forget:不阻塞本地拆隧道与 UI 回 off。 + Future _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 { _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() { diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index 2dcee45..76e3aa6 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -332,7 +332,17 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { // ─── POST /v1/nodes/{id}/disconnect ────────────────────────────────────────── +// disconnectRequest is the optional JSON body: device_id 指认要吊销凭证的设备。 +// 旧客户端不带 body(或不带 device_id)→ 仅吊销遗留账户级凭证(历史行为)。 +type disconnectRequest struct { + DeviceID string `json:"device_id"` +} + // DisconnectNode handles POST /v1/nodes/{id}/disconnect. +// +// F4:connect 下发的是每设备凭证(EnsureDeviceDpUUID),吊销也必须对准它—— +// 此前这里只撤账户级 ent.DpUUID,设备凭证一直活到 TTL(付费 24h),「断开」在 +// 服务端形同空转。现按 device_id 吊销该设备的 dp_uuid,账户级作为遗留兜底仍撤。 func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) { uid, ok := auth.UserIDFromContext(r.Context()) if !ok { @@ -346,6 +356,10 @@ func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) { return } + // Optional body(旧客户端无 body → device_id 为空,走兜底路径)。 + var req disconnectRequest + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)).Decode(&req) + // Load dp_uuid. ent, err := a.store.EntitlementForUser(r.Context(), uid) if err != nil { @@ -367,14 +381,33 @@ func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) { return } - // Push revoke command. - _ = a.hub.Push(r.Context(), node.UUID, &agentv1.Command{ - Type: agentv1.CommandTypeRevoke, - Revoke: &agentv1.RevokePayload{DpUUID: ent.DpUUID}, - }) + // 收集待吊销凭证:每设备(connect 真正下发的)+ 账户级(遗留兜底)。 + dpUUIDs := make([]string, 0, 2) + if devID := strings.TrimSpace(req.DeviceID); devID != "" { + devDp, _, derr := a.store.EnsureDeviceDpUUID(r.Context(), uid, devID) + if derr != nil && !errors.Is(derr, nodes.ErrDeviceNotFound) { + slog.Warn("disconnect: device dp_uuid lookup failed", "user", uid, "device", devID, "err", derr) + } + if devDp != "" { + dpUUIDs = append(dpUUIDs, devDp) + } + } + if ent.DpUUID != "" { + dpUUIDs = append(dpUUIDs, ent.DpUUID) + } - // Delete persisted credential. - _ = a.store.DeleteCredential(r.Context(), node.ID, ent.DpUUID) + for _, dp := range dpUUIDs { + // Push revoke command(best-effort:agent 离线时命令进 Redis 队列,重连即达; + // 删除持久化凭证后 resync 也不会再下发)。nil hub = 测试环境,跳过推送。 + if a.hub != nil { + _ = a.hub.Push(r.Context(), node.UUID, &agentv1.Command{ + Type: agentv1.CommandTypeRevoke, + Revoke: &agentv1.RevokePayload{DpUUID: dp}, + }) + } + // Delete persisted credential. + _ = a.store.DeleteCredential(r.Context(), node.ID, dp) + } w.WriteHeader(http.StatusNoContent) } diff --git a/server/internal/httpapi/nodes_disconnect_test.go b/server/internal/httpapi/nodes_disconnect_test.go new file mode 100644 index 0000000..d8aab8a --- /dev/null +++ b/server/internal/httpapi/nodes_disconnect_test.go @@ -0,0 +1,113 @@ +package httpapi + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/wangjia/pangolin/server/internal/codes" + "github.com/wangjia/pangolin/server/internal/nodes" + agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" +) + +// fakeDisconnectStore implements the NodeStore methods DisconnectNode touches; +// everything else panics via the embedded nil interface (未用到即安全)。 +type fakeDisconnectStore struct { + nodes.NodeStore // embed:未实现的方法调用即 panic,测试只走下面四个 + + node *nodes.NodeRow + ent *nodes.Entitlement + devDp string // EnsureDeviceDpUUID 返回值;"" = 设备不存在 + deleted []string +} + +func (f *fakeDisconnectStore) EntitlementForUser(context.Context, int64) (*nodes.Entitlement, error) { + return f.ent, nil +} + +func (f *fakeDisconnectStore) NodeByUUID(context.Context, string) (*nodes.NodeRow, error) { + return f.node, nil +} + +func (f *fakeDisconnectStore) EnsureDeviceDpUUID(context.Context, int64, string) (string, int64, error) { + if f.devDp == "" { + return "", 0, nodes.ErrDeviceNotFound + } + return f.devDp, 7, nil +} + +func (f *fakeDisconnectStore) DeleteCredential(_ context.Context, _ int64, dpUUID string) error { + f.deleted = append(f.deleted, dpUUID) + return nil +} + +func (f *fakeDisconnectStore) PersistCredential(context.Context, int64, *agentv1.Credential, time.Time) error { + return nil +} + +func doDisconnect(t *testing.T, store *fakeDisconnectStore, body string) int { + t.Helper() + api := NewNodeAPI(store, nil, nil, "", "") // nil hub:跳过 Push,只验证凭证删除 + req := httptest.NewRequest("POST", "/v1/nodes/node-1/disconnect", strings.NewReader(body)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "node-1") + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, codes.CtxKeyUserID, int64(42)) + rec := httptest.NewRecorder() + api.DisconnectNode(rec, req.WithContext(ctx)) + return rec.Code +} + +// F4 回归:带 device_id 的 disconnect 必须吊销**每设备** dp_uuid(connect 真正 +// 下发的那个),账户级作为遗留兜底也一并吊销。 +func TestDisconnectNode_RevokesDeviceCredential(t *testing.T) { + store := &fakeDisconnectStore{ + node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"}, + ent: &nodes.Entitlement{DpUUID: "acct-dp"}, + devDp: "device-dp", + } + if code := doDisconnect(t, store, `{"device_id":"dev-uuid-1"}`); code != 204 { + t.Fatalf("status = %d, want 204", code) + } + want := map[string]bool{"device-dp": true, "acct-dp": true} + if len(store.deleted) != 2 || !want[store.deleted[0]] || !want[store.deleted[1]] { + t.Errorf("deleted = %v, want both device-dp and acct-dp", store.deleted) + } + // 每设备凭证在前(connect 真正下发的),账户级兜底在后。 + if store.deleted[0] != "device-dp" { + t.Errorf("device credential should be revoked first, got %v", store.deleted) + } +} + +// 旧客户端无 body → 仅账户级兜底(历史行为不回归)。 +func TestDisconnectNode_LegacyNoBody(t *testing.T) { + store := &fakeDisconnectStore{ + node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"}, + ent: &nodes.Entitlement{DpUUID: "acct-dp"}, + } + if code := doDisconnect(t, store, ""); code != 204 { + t.Fatalf("status = %d, want 204", code) + } + if len(store.deleted) != 1 || store.deleted[0] != "acct-dp" { + t.Errorf("deleted = %v, want only acct-dp", store.deleted) + } +} + +// 设备不存在(已被移除)→ 不炸,仍撤账户级。 +func TestDisconnectNode_DeviceGone(t *testing.T) { + store := &fakeDisconnectStore{ + node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"}, + ent: &nodes.Entitlement{DpUUID: "acct-dp"}, + devDp: "", // ErrDeviceNotFound + } + if code := doDisconnect(t, store, `{"device_id":"gone"}`); code != 204 { + t.Fatalf("status = %d, want 204", code) + } + if len(store.deleted) != 1 || store.deleted[0] != "acct-dp" { + t.Errorf("deleted = %v, want only acct-dp", store.deleted) + } +}