fix(server): 连接删掉账户级 dp_uuid 回退,未注册设备直接拒(#16)
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Has been cancelled
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Has been cancelled
ci-pangolin / Go — build + test (push) Has been cancelled
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Has been cancelled
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Has been cancelled
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Has been cancelled

漏洞:设备被移除后行已删,但靠残留 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-01 22:51:12 +08:00
parent 6ea2ec4a32
commit 0bedd1c4d1
2 changed files with 26 additions and 10 deletions
+20 -9
View File
@@ -3,6 +3,7 @@ package httpapi
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
@@ -220,16 +221,26 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
} }
expiresAt := time.Now().UTC().Add(ttl) expiresAt := time.Now().UTC().Add(ttl)
// 2.5 Per-device dp_uuid (todo #5 Phase 2): each device gets its own data-plane // 2.5 Per-device dp_uuid:每台已注册设备一个专属数据面凭证。设备未注册/已被移除 →
// credential so the node reports per-device traffic counters. Falls back to the // **拒连并提示重新登录(重新注册设备)**,不再回退账户级 dp_uuid —— 否则被移除的设备
// account-level dp_uuid when the device isn't registered yet (legacy clients). // 靠残留 access token + 回退还能钻进来,绕过设备上限(#16)。
dpUUID := ent.DpUUID devDp, _, derr := a.store.EnsureDeviceDpUUID(r.Context(), uid, req.DeviceID)
if devDp, _, derr := a.store.EnsureDeviceDpUUID(r.Context(), uid, req.DeviceID); derr == nil && devDp != "" { if errors.Is(derr, nodes.ErrDeviceNotFound) || (derr == nil && devDp == "") {
dpUUID = devDp w.Header().Set("Content-Type", "application/json; charset=utf-8")
} else if derr != nil { w.WriteHeader(http.StatusForbidden)
slog.Info("connect: per-device dp_uuid unavailable, using account credential", _ = json.NewEncoder(w).Encode(map[string]any{
"user", uid, "device", req.DeviceID, "err", derr.Error()) "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. // 3. Resolve node.
node, err := a.store.NodeByUUID(r.Context(), nodeUUID) node, err := a.store.NodeByUUID(r.Context(), nodeUUID)
+6 -1
View File
@@ -3,6 +3,7 @@ package nodes
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"time" "time"
@@ -11,6 +12,10 @@ import (
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" 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. // NodeRow holds a node's essential fields from the nodes table.
type NodeRow struct { type NodeRow struct {
ID int64 ID int64
@@ -375,7 +380,7 @@ func (s *SQLNodeStore) EnsureDeviceDpUUID(ctx context.Context, userID int64, dev
userID, deviceUUID, userID, deviceUUID,
).Scan(&deviceID, &dpUUID) ).Scan(&deviceID, &dpUUID)
if err == sql.ErrNoRows { 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 { if err != nil {
return "", 0, fmt.Errorf("nodes.SQLNodeStore.EnsureDeviceDpUUID: %w", err) return "", 0, fmt.Errorf("nodes.SQLNodeStore.EnsureDeviceDpUUID: %w", err)