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
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user