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(), } }