From 0bedd1c4d17862e6a26435dab1d528b3c490e048 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 1 Jul 2026 22:51:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E8=BF=9E=E6=8E=A5=E5=88=A0?= =?UTF-8?q?=E6=8E=89=E8=B4=A6=E6=88=B7=E7=BA=A7=20dp=5Fuuid=20=E5=9B=9E?= =?UTF-8?q?=E9=80=80,=E6=9C=AA=E6=B3=A8=E5=86=8C=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E6=8B=92(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 漏洞:设备被移除后行已删,但靠残留 access token + 连接时回退「账户级 dp_uuid」 还能连上(EnsureDeviceDpUUID 找不到设备→回退),且不计入活跃设备数,绕过设备上限。 修:ConnectNode 去掉账户级回退。设备未注册/已被移除(EnsureDeviceDpUUID 返回 ErrDeviceNotFound)→ 403 {code:DEVICE_NOT_REGISTERED} + 提示「请重新登录以重新 注册设备」。客户端现有 ConnectApiException 处理会把该 message_zh 显示在连接页, 用户重新登录即重新注册(并命中登录挡板/超限流程)。 - nodes: 新增哨兵 ErrDeviceNotFound;EnsureDeviceDpUUID 包装它 - httpapi ConnectNode: errors.Is(ErrDeviceNotFound) → 拒连;其余错误 500 go build/vet 干净;nodes/httpapi 测试全过。客户端不用改。 Co-Authored-By: Claude Opus 4.8 --- server/internal/httpapi/nodes.go | 29 ++++++++++++++++++++--------- server/internal/nodes/store.go | 7 ++++++- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index ca1ef90..d36a3f6 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -3,6 +3,7 @@ package httpapi import ( "context" "encoding/json" + "errors" "log/slog" "net/http" "strconv" @@ -220,16 +221,26 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { } expiresAt := time.Now().UTC().Add(ttl) - // 2.5 Per-device dp_uuid (todo #5 Phase 2): each device gets its own data-plane - // credential so the node reports per-device traffic counters. Falls back to the - // account-level dp_uuid when the device isn't registered yet (legacy clients). - dpUUID := ent.DpUUID - if devDp, _, derr := a.store.EnsureDeviceDpUUID(r.Context(), uid, req.DeviceID); derr == nil && devDp != "" { - dpUUID = devDp - } else if derr != nil { - slog.Info("connect: per-device dp_uuid unavailable, using account credential", - "user", uid, "device", req.DeviceID, "err", derr.Error()) + // 2.5 Per-device dp_uuid:每台已注册设备一个专属数据面凭证。设备未注册/已被移除 → + // **拒连并提示重新登录(重新注册设备)**,不再回退账户级 dp_uuid —— 否则被移除的设备 + // 靠残留 access token + 回退还能钻进来,绕过设备上限(#16)。 + devDp, _, derr := a.store.EnsureDeviceDpUUID(r.Context(), uid, req.DeviceID) + if errors.Is(derr, nodes.ErrDeviceNotFound) || (derr == nil && devDp == "") { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "DEVICE_NOT_REGISTERED", + "message_zh": "设备未注册或已被移除,请退出后重新登录以重新注册设备", + "message_en": "Device not registered. Please sign in again to re-register this device.", + }) + return } + if derr != nil { + slog.Error("connect: ensure device dp_uuid failed", "user", uid, "device", req.DeviceID, "err", derr) + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + dpUUID := devDp // 3. Resolve node. node, err := a.store.NodeByUUID(r.Context(), nodeUUID) diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index 82b3da0..e3aaa25 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -3,6 +3,7 @@ package nodes import ( "context" "database/sql" + "errors" "fmt" "time" @@ -11,6 +12,10 @@ import ( agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" ) +// ErrDeviceNotFound: connect 时 device_id 不是该用户的已注册设备(未注册或已被移除)。 +// connect 据此拒连并提示重新登录(重新注册设备),不再回退账户级 dp_uuid。 +var ErrDeviceNotFound = errors.New("device not registered") + // NodeRow holds a node's essential fields from the nodes table. type NodeRow struct { ID int64 @@ -375,7 +380,7 @@ func (s *SQLNodeStore) EnsureDeviceDpUUID(ctx context.Context, userID int64, dev userID, deviceUUID, ).Scan(&deviceID, &dpUUID) if err == sql.ErrNoRows { - return "", 0, fmt.Errorf("nodes.SQLNodeStore.EnsureDeviceDpUUID: device %q not found for user %d", deviceUUID, userID) + return "", 0, fmt.Errorf("nodes.SQLNodeStore.EnsureDeviceDpUUID: device %q for user %d: %w", deviceUUID, userID, ErrDeviceNotFound) } if err != nil { return "", 0, fmt.Errorf("nodes.SQLNodeStore.EnsureDeviceDpUUID: %w", err)