From ba625ffcd3507d7eee9d99077bac0a6357206771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 15 Aug 2026 01:17:19 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20device:=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=B5=81=E8=A1=A5=E4=B8=8A=E8=87=AA=E6=8A=A5=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=90=8D=EF=BC=8C=E5=9B=9E=E9=80=80=E4=B8=8D=E5=86=8D=E5=88=87?= =?UTF-8?q?=E5=9C=A8=20sha256:=20=E5=89=8D=E7=BC=80=E9=87=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设备流此前没有任何途径接收设备名:授权请求只收 device_kind / fingerprint / platform / version,换取 token 时只能 flow.ClientFingerprint[:8] 当名字。而 daemon 与桌面端的规范指纹是 sha256:<64 位 hex>,切出来恒为 "sha256:" 加一个十六进制字符 ——整个账号下的机器最多只有 16 种名字,等于没有名字。 授权请求补上可选的 name,落进 device_flow_codes.client_name,换取 token 时由 device_entity.DisplayName 决定 devices.name:自报优先,缺省回退到**剥掉 sha256: 前缀后**的指纹缩写。老客户端不带 name 照常授权。 回退规则放在实体层而不是 service:它是「这台设备叫什么」的业务判据。按符文而不是 按字节截 —— 端点按 binding `min=8` 收而 validator 数的是符文,八个多字节符文的指纹 过得了校验,按字节切却会切在符文中间,落库是一段非法 UTF-8。 迁移编号取 202608150002:202608150001 已被同日的「浏览器改用短效中继凭据」占用。 gormigrate 按 ID 记账,同 ID 的两条只会跑一条、另一条被当成已执行,client_name 列 会在已记录该 ID 的库上永远不被创建且不报错。 客户端侧(agentred 与桌面端上报主机名)在 agentre 仓库的 #55。 --- internal/api/device/device.go | 4 ++ internal/controller/device_ctr/device.go | 2 +- internal/controller/device_ctr/device_test.go | 28 +++++++++++++ internal/model/entity/device_entity/device.go | 32 +++++++++++++- .../model/entity/device_entity/device_test.go | 28 +++++++++++++ .../entity/device_flow_entity/device_flow.go | 23 +++++----- internal/service/device_svc/device.go | 3 +- internal/service/device_svc/device_test.go | 42 +++++++++++++++++++ internal/service/device_svc/types.go | 2 + .../202608150002_device_flow_client_name.go | 34 +++++++++++++++ migrations/migrations.go | 1 + 11 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 migrations/202608150002_device_flow_client_name.go diff --git a/internal/api/device/device.go b/internal/api/device/device.go index a4a0d2da..16d596d1 100644 --- a/internal/api/device/device.go +++ b/internal/api/device/device.go @@ -24,6 +24,10 @@ type DeviceAuthorizeRequest struct { Fingerprint string `json:"fingerprint" binding:"required,min=8,max=128"` Platform string `json:"platform" binding:"max=64"` Version string `json:"version"` + // Name 是设备自报的显示名(通常是主机名)。可空 —— 不带它的老客户端照常授权, + // 设备名回退到指纹缩写。设备流没有第二条途径拿到这个名字:不在这里带上, + // 设备列表里每台机器就都只能叫指纹缩写。 + Name string `json:"name" binding:"max=128"` } type DeviceAuthorizeResponse struct { DeviceCode string `json:"device_code"` diff --git a/internal/controller/device_ctr/device.go b/internal/controller/device_ctr/device.go index 62564c8d..0afe0c5d 100644 --- a/internal/controller/device_ctr/device.go +++ b/internal/controller/device_ctr/device.go @@ -45,7 +45,7 @@ func (d *Device) PublicKey(c *gin.Context, _ *api.PublicKeyRequest) { func (d *Device) Authorize(ctx context.Context, req *api.DeviceAuthorizeRequest) (*api.DeviceAuthorizeResponse, error) { out, err := device_svc.Default().Authorize(ctx, device_svc.AuthorizeInput{ DeviceKind: req.DeviceKind, Fingerprint: req.Fingerprint, - Platform: req.Platform, Version: req.Version, + Platform: req.Platform, Version: req.Version, Name: req.Name, }) if err != nil { return nil, i18n.NewInternalError(ctx, code.ServerError) diff --git a/internal/controller/device_ctr/device_test.go b/internal/controller/device_ctr/device_test.go index 386e653d..adcd6c92 100644 --- a/internal/controller/device_ctr/device_test.go +++ b/internal/controller/device_ctr/device_test.go @@ -345,6 +345,34 @@ func TestAuthorize_IgnoresLegacyCapabilitiesField(t *testing.T) { }, stub.authorizeInputs[0]) } +// 设备自报的显示名(通常是主机名)必须一路传到 service:设备流没有别的途径拿到 +// 它,缺了这一段,设备列表里每台机器都只能叫指纹缩写。 +func TestAuthorize_PassesReportedName(t *testing.T) { + stub := &stubDeviceSvc{} + server, _ := newDeviceTestServer(t, stub) + + body := `{"device_kind":"agentred","fingerprint":"fp-named-client","platform":"linux/amd64",` + + `"version":"v0.5.0","name":"coding"}` + resp := doRequest(t, http.MethodPost, server.URL+"/v1/oauth/device/authorize", "", "", body) + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.Len(t, stub.authorizeInputs, 1) + assert.Equal(t, "coding", stub.authorizeInputs[0].Name) +} + +// 老客户端不带 name,授权照常成立(名字回退到指纹缩写,由 service 决定)。 +func TestAuthorize_NameIsOptional(t *testing.T) { + stub := &stubDeviceSvc{} + server, _ := newDeviceTestServer(t, stub) + + body := `{"device_kind":"agentred","fingerprint":"fp-unnamed-client","platform":"linux/amd64","version":"v0.5.0"}` + resp := doRequest(t, http.MethodPost, server.URL+"/v1/oauth/device/authorize", "", "", body) + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.Len(t, stub.authorizeInputs, 1) + assert.Empty(t, stub.authorizeInputs[0].Name) +} + // nightly 构建会在语义版本后附带提交与构建元数据,长度可能超过 32 个字符。 // version 只是展示信息,且存储列可容纳 64 个字符,授权入口不能提前拒绝它。 func TestAuthorize_AcceptsLongNightlyVersion(t *testing.T) { diff --git a/internal/model/entity/device_entity/device.go b/internal/model/entity/device_entity/device.go index 148518b6..423febb5 100644 --- a/internal/model/entity/device_entity/device.go +++ b/internal/model/entity/device_entity/device.go @@ -1,7 +1,11 @@ // Package device_entity 维护设备实体。 package device_entity -import "github.com/cago-frame/cago/pkg/consts" +import ( + "strings" + + "github.com/cago-frame/cago/pkg/consts" +) const ( KindDesktop = "desktop" @@ -27,3 +31,29 @@ type Device struct { func (*Device) TableName() string { return "devices" } func (d *Device) IsActive() bool { return d != nil && d.Status == consts.ACTIVE } + +// fingerprintPrefix 是 daemon 侧规范指纹的算法前缀(sha256:<64 位 hex>)。 +const fingerprintPrefix = "sha256:" + +// displayNameFallbackRunes 是回退名取的指纹符文数。 +const displayNameFallbackRunes = 8 + +// DisplayName 返回设备列表里显示的名字:客户端自报的名字优先,缺省时回退到指纹缩写。 +// +// 回退**先剥掉 sha256: 前缀再截**:daemon 与桌面端的规范指纹都是 sha256:<64 位 hex>, +// 直接截前 8 个字符拿到的是 "sha256:" 加一个十六进制字符 —— 整个账号下的机器最多只有 +// 16 种名字,等于没有名字。 +// +// 按符文而不是按字节截:指纹由客户端自己生成,端点只按 binding `min=8` 收,而 validator +// 的 min 数的正是符文 —— 八个多字节符文的指纹过得了校验,按字节切却会切在符文中间, +// 落库的是一段非法 UTF-8,数据库会拒掉整条 INSERT。 +func DisplayName(reported, fingerprint string) string { + if name := strings.TrimSpace(reported); name != "" { + return name + } + runes := []rune(strings.TrimPrefix(fingerprint, fingerprintPrefix)) + if len(runes) <= displayNameFallbackRunes { + return string(runes) + } + return string(runes[:displayNameFallbackRunes]) +} diff --git a/internal/model/entity/device_entity/device_test.go b/internal/model/entity/device_entity/device_test.go index ba989f69..1ea1069d 100644 --- a/internal/model/entity/device_entity/device_test.go +++ b/internal/model/entity/device_entity/device_test.go @@ -12,3 +12,31 @@ func TestDevice_IsActive(t *testing.T) { assert.False(t, (&Device{Status: consts.DELETE}).IsActive()) assert.False(t, (*Device)(nil).IsActive()) } + +func TestDisplayName(t *testing.T) { + // daemon 侧的规范指纹形态:sha256:<64 位 hex>(rpc.DaemonFingerprint)。 + const daemonFP = "sha256:475776c61078781c9fda7b3345d232e32d5f176a7220ce2d129c5e39ac2db3de" + + t.Run("自报了名字就用它", func(t *testing.T) { + assert.Equal(t, "coding", DisplayName("coding", daemonFP)) + }) + t.Run("自报名字只有空白视同没报", func(t *testing.T) { + assert.Equal(t, "475776c6", DisplayName(" ", daemonFP)) + }) + t.Run("没自报时回退到指纹缩写,且不能把 sha256: 前缀算进去", func(t *testing.T) { + // 直接截前 8 个字符会得到 "sha256:4"——每台机器都长一样,等于没有名字。 + assert.Equal(t, "475776c6", DisplayName("", daemonFP)) + }) + t.Run("浏览器那种无前缀指纹按原样取前 8 位", func(t *testing.T) { + assert.Equal(t, "b363ed8b", DisplayName("", "b363ed8b7fdd0175e6d08ea8")) + }) + t.Run("指纹本身不足 8 位就整串返回", func(t *testing.T) { + assert.Equal(t, "ab12", DisplayName("", "ab12")) + assert.Equal(t, "", DisplayName("", "")) + }) + t.Run("按符文截,不切碎多字节指纹", func(t *testing.T) { + // 端点只按 binding `min=8` 收,而 validator 数的是符文:八个多字节符文过得了 + // 校验,按字节切却会切在符文中间,落库时 MySQL 直接拒掉整条 INSERT。 + assert.Equal(t, "一二三四五六七八", DisplayName("", "一二三四五六七八九十")) + }) +} diff --git a/internal/model/entity/device_flow_entity/device_flow.go b/internal/model/entity/device_flow_entity/device_flow.go index 7fc5ad5f..fa7fcacc 100644 --- a/internal/model/entity/device_flow_entity/device_flow.go +++ b/internal/model/entity/device_flow_entity/device_flow.go @@ -6,16 +6,19 @@ type DeviceFlowCode struct { UserCode string `gorm:"column:user_code;type:text;not null"` DeviceKind string `gorm:"column:device_kind;type:text;not null"` ClientFingerprint string `gorm:"column:client_fingerprint;type:text;not null"` - Platform string `gorm:"column:platform;type:text;not null;default:''"` - Version string `gorm:"column:version;type:text;not null;default:''"` - AuthorizedUserID int64 `gorm:"column:authorized_user_id;type:bigint;not null;default:0"` - ApprovedAt int64 `gorm:"column:approved_at;type:bigint;not null;default:0"` - ConsumedAt int64 `gorm:"column:consumed_at;type:bigint;not null;default:0"` - DeniedAt int64 `gorm:"column:denied_at;type:bigint;not null;default:0"` - IntervalSeconds int `gorm:"column:interval_seconds;type:smallint;not null;default:5"` - LastPolledAt int64 `gorm:"column:last_polled_at;type:bigint;not null;default:0"` - ExpiresAt int64 `gorm:"column:expires_at;type:bigint;not null;default:0"` - Createtime int64 `gorm:"column:createtime;type:bigint;not null;default:0"` + // ClientName 是客户端自报的显示名(通常是主机名),可空;换取 token 时决定 + // devices.name,缺省则回退到指纹缩写。 + ClientName string `gorm:"column:client_name;type:text;not null;default:''"` + Platform string `gorm:"column:platform;type:text;not null;default:''"` + Version string `gorm:"column:version;type:text;not null;default:''"` + AuthorizedUserID int64 `gorm:"column:authorized_user_id;type:bigint;not null;default:0"` + ApprovedAt int64 `gorm:"column:approved_at;type:bigint;not null;default:0"` + ConsumedAt int64 `gorm:"column:consumed_at;type:bigint;not null;default:0"` + DeniedAt int64 `gorm:"column:denied_at;type:bigint;not null;default:0"` + IntervalSeconds int `gorm:"column:interval_seconds;type:smallint;not null;default:5"` + LastPolledAt int64 `gorm:"column:last_polled_at;type:bigint;not null;default:0"` + ExpiresAt int64 `gorm:"column:expires_at;type:bigint;not null;default:0"` + Createtime int64 `gorm:"column:createtime;type:bigint;not null;default:0"` } func (*DeviceFlowCode) TableName() string { return "device_flow_codes" } diff --git a/internal/service/device_svc/device.go b/internal/service/device_svc/device.go index 4c3d62d5..8452a7a9 100644 --- a/internal/service/device_svc/device.go +++ b/internal/service/device_svc/device.go @@ -93,6 +93,7 @@ func (s *deviceSvc) Authorize(ctx context.Context, in AuthorizeInput) (*Authoriz UserCode: uc, DeviceKind: in.DeviceKind, ClientFingerprint: in.Fingerprint, + ClientName: in.Name, Platform: in.Platform, Version: in.Version, IntervalSeconds: int(s.cfg.PollInterval / time.Second), @@ -197,7 +198,7 @@ func (s *deviceSvc) ExchangeToken(ctx context.Context, dc string) (*TokenOutput, d := &device_entity.Device{ UserID: flow.AuthorizedUserID, - Name: flow.ClientFingerprint[:8], + Name: device_entity.DisplayName(flow.ClientName, flow.ClientFingerprint), Kind: flow.DeviceKind, Platform: flow.Platform, Version: flow.Version, diff --git a/internal/service/device_svc/device_test.go b/internal/service/device_svc/device_test.go index 4338830e..55c197a9 100644 --- a/internal/service/device_svc/device_test.go +++ b/internal/service/device_svc/device_test.go @@ -68,12 +68,15 @@ func TestAuthorize_ReturnsUserCode(t *testing.T) { mF.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, code *device_flow_entity.DeviceFlowCode) error { assert.Equal(t, "agentred", code.DeviceKind) + // 自报名字必须落进 flow 行:换取 token 时 devices.name 只认它。 + assert.Equal(t, "coding", code.ClientName) return nil }, ) out, err := svc.Authorize(ctx, AuthorizeInput{ DeviceKind: "agentred", Fingerprint: "fp-aaaaaaaa", Platform: "linux/amd64", Version: "0.5.0", + Name: "coding", }) assert.NoError(t, err) assert.NotEmpty(t, out.DeviceCode) @@ -158,6 +161,45 @@ func TestExchangeToken(t *testing.T) { assert.Equal(t, int64(7), out.DeviceID) assert.Equal(t, out.JTI, capturedJTI) }) + // 设备流的显示名:客户端自报优先,缺省回退到指纹缩写。回退**必须**剥掉 + // sha256: 前缀 —— 直接截前 8 个字符得到的是 "sha256:" 加一个十六进制字符, + // 整个账号下的机器最多只有 16 种名字。 + exchangeNamed := func(t *testing.T, reported string) string { + ctx, mD, mT, mF, svc, mock := setupDeviceTest(t) + mF.EXPECT().FindByDeviceCode(gomock.Any(), "dc-x").Return( + &device_flow_entity.DeviceFlowCode{ + DeviceCode: "dc-x", IntervalSeconds: 5, + ExpiresAt: time.Now().Add(time.Hour).UnixMilli(), + AuthorizedUserID: 42, ApprovedAt: time.Now().UnixMilli(), + DeviceKind: "agentred", + ClientFingerprint: "sha256:475776c61078781c9fda7b3345d232e32d5f176a7220ce2d129c5e39ac2db3de", + ClientName: reported, + }, nil, + ) + mF.EXPECT().UpdateLastPolled(gomock.Any(), "dc-x", gomock.Any()).Return(nil) + var name string + mD.EXPECT().Upsert(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, d *device_entity.Device) error { + name = d.Name + d.ID = 7 + return nil + }, + ) + mT.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + mF.EXPECT().MarkConsumed(gomock.Any(), "dc-x", gomock.Any()).Return(int64(1), nil) + mock.ExpectBegin() + mock.ExpectCommit() + + _, err := svc.ExchangeToken(ctx, "dc-x") + assert.NoError(t, err) + return name + } + convey.Convey("设备名取客户端自报的主机名", func() { + assert.Equal(t, "coding", exchangeNamed(t, "coding")) + }) + convey.Convey("客户端没自报名字时回退到指纹缩写", func() { + assert.Equal(t, "475776c6", exchangeNamed(t, "")) + }) convey.Convey("并发竞败(MarkConsumed 命中 0 行)→ invalid_grant,且在写 device 之前就出局", func() { ctx, mD, mT, mF, svc, mock := setupDeviceTest(t) mF.EXPECT().FindByDeviceCode(gomock.Any(), "dc-x").Return( diff --git a/internal/service/device_svc/types.go b/internal/service/device_svc/types.go index b148ee5d..a6232720 100644 --- a/internal/service/device_svc/types.go +++ b/internal/service/device_svc/types.go @@ -20,6 +20,8 @@ type AuthorizeInput struct { Fingerprint string Platform string Version string + // Name 是客户端自报的显示名(通常是主机名),可空 —— 缺省时设备名回退到指纹缩写。 + Name string } type AuthorizeOutput struct { diff --git a/migrations/202608150002_device_flow_client_name.go b/migrations/202608150002_device_flow_client_name.go new file mode 100644 index 00000000..788c8928 --- /dev/null +++ b/migrations/202608150002_device_flow_client_name.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// migration202608150002 给 device_flow_codes 加上客户端自报的显示名。 +// +// 设备流此前没有任何途径接收设备名:授权请求只收 device_kind / fingerprint / +// platform / version,换取 token 时只能把指纹截前 8 个字符当名字。而 daemon 与桌面端 +// 的规范指纹是 sha256:<64 位 hex>,截出来的是 "sha256:" 加一个十六进制字符——整个 +// 账号下的机器最多只有 16 种名字。名字要有意义,就必须由客户端在授权时报上来, +// 而授权与换取 token 是两次独立的请求,中间只有这一行 flow 记录能承载它。 +// +// +// NOT NULL DEFAULT ”:老客户端不带 name,既有的未消费 flow 行也没有这一列, +// 空串表示「没自报」,由 device_entity.DisplayName 回退到指纹缩写。 +// +// varchar(128) 与授权端点的 binding `max=128` 对齐,也与同表 platform / version 那批 +// 有界 varchar 的写法一致——这张表里没有 text 列。 +func migration202608150002() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "202608150002", + Migrate: func(tx *gorm.DB) error { + return tx.Exec( + "ALTER TABLE device_flow_codes ADD COLUMN client_name varchar(128) NOT NULL DEFAULT ''", + ).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("ALTER TABLE device_flow_codes DROP COLUMN client_name").Error + }, + } +} diff --git a/migrations/migrations.go b/migrations/migrations.go index be20db34..1352eb85 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -122,5 +122,6 @@ func migrationList() []*gormigrate.Migration { migration202608100001(), migration202608140001(), migration202608150001(), + migration202608150002(), } }