From 5dbb67f1a5ce1b9f8d0154a7ce9c6fbcb2609368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=A0=E4=B9=A0?= <25678988+xfxx2022@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:46:21 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(security):=20=E6=B6=88=E9=99=A4=20CodeQ?= =?UTF-8?q?L=20=E5=91=8A=E8=AD=A6=E5=B9=B6=E4=BF=AE=E5=A4=8D=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E=20panic=20?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代码扫描(CodeQL 默认设置)共 4 条告警,逐条复核后分别处理: 1. go/disabled-certificate-check @ internal/ctyun/clink/tls.go Clink 以 IP 作为 wss endpoint,服务端只提供 *.ctyun.cn 证书且线上存在已过期 旧证书,Go 的 crypto/tls 没有“仅忽略有效期”的开关,因此必须关闭默认 verifier 再由 VerifyConnection 复刻兼容策略。校验逻辑仍完整(链签名、ctyun.cn 归属、 尚未生效一律拒绝),不存在无条件放行路径。此处加定点抑制注释并补充说明。 2. go/weak-sensitive-data-hashing @ internal/ctyun/auth/sign.go SHA256Hex 是通用摘要工具,同时服务请求签名与天翼登录协议。登录流程只把它作为 一次性传输摘要提交,算法由服务端固定要求,并非本地口令存储。补充说明并抑制。 3. go/weak-sensitive-data-hashing @ internal/ctyun/auth/clink_login.go 天翼旧 Clink 鉴权协议固定使用 MD5 签名,客户端无法更换算法。补充说明并抑制。 4. go/allocation-size-overflow @ internal/ctyun/clink/protocol.go 帧长度用 int 累加,32 位平台上可能先溢出再按错误大小分配缓冲区。新增 maxMessagePayloadBytes(16 MiB)上限校验后 panic,由上层 RecoverPanic 边界 记录,避免静默截断或错误分配。这是真实修复,未使用抑制。 审查中发现并一并修复的缺陷: - internal/app/runtime.go:refreshPointsAsync 的后台 goroutine 缺少 panic 恢复, 单次刷新 panic 会直接终结整个保活进程。补齐 RecoverPanic 边界。 - internal/app/automation.go:TaskAutomation.Start 的启动刷新 goroutine 存在同样 问题,补齐 RecoverPanic 边界。 - internal/automation/redeem_job.go:commitState 原先先改内存再写盘,落盘失败时 进程内状态与磁盘状态漂移(重启后 pending 丢失)。改为先落盘、成功后再更新内存, 与 ResolvePending 的既有原则保持一致。 未纳入本次改动(需产品决策,见审查报告):PointsJob.WaitUsageAndRefresh 超时后 仍继续兑换的行为、storage 层 config.json 读-改-写的跨服务竞态。 --- internal/app/automation.go | 6 +++++- internal/app/runtime.go | 7 ++++++- internal/automation/redeem_job.go | 14 ++++++++++---- internal/ctyun/auth/clink_login.go | 2 +- internal/ctyun/auth/sign.go | 7 ++++++- internal/ctyun/clink/protocol.go | 11 +++++++++++ internal/ctyun/clink/tls.go | 12 ++++++++++-- 7 files changed, 49 insertions(+), 10 deletions(-) diff --git a/internal/app/automation.go b/internal/app/automation.go index eb9a1c0..39d3125 100644 --- a/internal/app/automation.go +++ b/internal/app/automation.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/uvwt/CtyunHelper/internal/automation" + "github.com/uvwt/CtyunHelper/internal/logging" ) const ( @@ -108,7 +109,10 @@ func (a *TaskAutomation) Start(ctx context.Context) { // 启动时只做一次只读积分刷新,不触发兑换;这样当天 04:00/06:00 已错过 // 也能尽快把余额和“使用1小时”状态显示到 UI。 if a.pointsJob != nil { - go func() { _ = a.RunPoints(ctx) }() + go func() { + defer logging.RecoverPanic("app.points_refresh_startup") + _ = a.RunPoints(ctx) + }() } } diff --git a/internal/app/runtime.go b/internal/app/runtime.go index b917fdd..e0ef2b3 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -151,7 +151,12 @@ func (r *Runtime) refreshPointsAsync() { if r == nil || r.automation == nil { return } - go func() { _ = r.RunPointsTask() }() + go func() { + // 与其它 goroutine 边界保持一致:积分刷新的 panic 只记录并终止本次后台 + // 任务,不能让默认 panic 直接终结整个保活进程。 + defer logging.RecoverPanic("app.points_refresh_async") + _ = r.RunPointsTask() + }() } func (r *Runtime) RunRedeemTask() error { diff --git a/internal/automation/redeem_job.go b/internal/automation/redeem_job.go index af80356..ceb2020 100644 --- a/internal/automation/redeem_job.go +++ b/internal/automation/redeem_job.go @@ -450,12 +450,18 @@ func parseProductTime(value string, location *time.Location) (time.Time, bool) { return time.Time{}, false } +// commitState 先落盘、成功后再更新内存状态,与 ResolvePending 保持同一原则。 +// 若先改内存再写盘,落盘失败时进程内会认为 pending/成功已生效,而重启后从磁盘 +// 读回的是旧状态(例如 pending 丢失 → 界面显示“结果不确定”,重启后却允许再次 +// 尝试下单),形成界面与实际不一致的漂移。 func (j *RedeemJob) commitState(state RedeemState) error { + if j.save != nil { + if err := j.save(state); err != nil { + return err + } + } j.mu.Lock() j.state = state j.mu.Unlock() - if j.save == nil { - return nil - } - return j.save(state) + return nil } diff --git a/internal/ctyun/auth/clink_login.go b/internal/ctyun/auth/clink_login.go index 2c9540a..cfbac53 100644 --- a/internal/ctyun/auth/clink_login.go +++ b/internal/ctyun/auth/clink_login.go @@ -102,7 +102,7 @@ func (c *Client) LegacyClinkHeaders() (http.Header, error) { timestamp := strconv.FormatInt(c.now().UnixMilli(), 10) source := identity.DeviceType + timestamp + strconv.FormatInt(profile.TenantID, 10) + timestamp + strconv.FormatInt(profile.UserID, 10) + identity.Version + profile.SecretKey - digest := md5.Sum([]byte(source)) + digest := md5.Sum([]byte(source)) // codeql[go/weak-sensitive-data-hashing] -- 天翼旧 Clink 协议固定使用 MD5 签名,客户端无法更换算法 headers := c.legacyClinkBaseHeaders() headers.Set("ctg-userid", strconv.FormatInt(profile.UserID, 10)) diff --git a/internal/ctyun/auth/sign.go b/internal/ctyun/auth/sign.go index 3a02065..556be6f 100644 --- a/internal/ctyun/auth/sign.go +++ b/internal/ctyun/auth/sign.go @@ -6,11 +6,16 @@ import ( "strings" ) +// SHA256Hex 是通用摘要工具,被请求签名(PublicSignature/ServerNodeSignature) +// 与天翼登录协议同时使用。它不承担任何本地口令存储职责:登录流程只把结果作为 +// 一次性传输摘要提交给服务端,算法由天翼服务端固定要求,客户端无权更换。 func SHA256Hex(value string) string { - digest := sha256.Sum256([]byte(value)) + digest := sha256.Sum256([]byte(value)) // codeql[go/weak-sensitive-data-hashing] -- 协议规定的传输摘要,非本地口令存储 return hex.EncodeToString(digest[:]) } +// LoginPassword 复现天翼官方客户端的口令摘要:sha256(sha256(password) + challengeCode)。 +// 服务端按该固定算法校验,换成任何更“强”的算法都会导致登录失败。 func LoginPassword(password, challengeCode string) string { return SHA256Hex(SHA256Hex(password) + challengeCode) } diff --git a/internal/ctyun/clink/protocol.go b/internal/ctyun/clink/protocol.go index aad8b4e..463131e 100644 --- a/internal/ctyun/clink/protocol.go +++ b/internal/ctyun/clink/protocol.go @@ -60,6 +60,12 @@ type Message struct { Data []byte } +// maxMessagePayloadBytes 是单条 Clink 帧负载的内部上限。Clink 的 Size 字段是 +// uint32,而这里的长度用 int 累加;显式设限可以保证在 32 位平台上 +// 6+extra+len(m.Data) 不会先溢出再按错误的大小分配缓冲区。 +// 本包所有消息都由本地构造(token/deviceCode/短 JSON),16 MiB 远超真实用途。 +const maxMessagePayloadBytes = 16 << 20 + // Marshal 按 Clink 的 Type(uint16 LE) + Size(uint32 LE) + Data 编码。 // buildMessage=true 时,在 Data 前再写 dataLength 和固定偏移 8。 func (m Message) Marshal(buildMessage bool) []byte { @@ -67,6 +73,11 @@ func (m Message) Marshal(buildMessage bool) []byte { if buildMessage { extra = 8 } + if len(m.Data) > maxMessagePayloadBytes { + // 负载超过协议上限属于内部编程错误(帧头无法表达该长度), + // 直接 panic 由上层 RecoverPanic 边界记录,避免静默截断或错误分配。 + panic(fmt.Sprintf("clink: 消息负载 %d 字节超过上限 %d", len(m.Data), maxMessagePayloadBytes)) + } buf := make([]byte, 6+extra+len(m.Data)) binary.LittleEndian.PutUint16(buf[0:2], m.Type) binary.LittleEndian.PutUint32(buf[2:6], uint32(extra+len(m.Data))) diff --git a/internal/ctyun/clink/tls.go b/internal/ctyun/clink/tls.go index 6bdba97..8b92c2c 100644 --- a/internal/ctyun/clink/tls.go +++ b/internal/ctyun/clink/tls.go @@ -14,8 +14,16 @@ func newClinkTLSConfig(endpoint string) *tls.Config { return &tls.Config{ // Clink 目前以 IP 作为 wss endpoint,但服务端返回的是 *.ctyun.cn 证书, // 且线上仍存在已经过期的旧证书。关闭 Go 的默认 verifier 后立即由 - // VerifyConnection 执行更严格的 CtYun 专用验证,不做无条件放行。 - InsecureSkipVerify: true, //nolint:gosec -- custom verification below is mandatory + // VerifyConnection 执行更严格的 CtYun 专用验证,不做无条件放行: + // VerifyConnection 一定会被调用,返回错误即终止握手,因此不存在 + // “跳过校验”的路径。 + // + // Go 的 crypto/tls 没有“只忽略有效期”的开关,必须关闭默认 verifier + // 才能在 VerifyConnection 里复刻服务端要求的兼容策略。真正的校验逻辑 + // 见 verifyClinkPeer:证书链签名、ctyun.cn 域名归属、以及“尚未生效的 + // 证书一律拒绝”都被显式检查,只有“已过期”这一项被兼容性放宽。 + // codeql[go/disabled-certificate-check] -- 定点抑制,理由见上 + InsecureSkipVerify: true, //nolint:gosec -- 自定义校验为强制路径 VerifyConnection: func(state tls.ConnectionState) error { return verifyClinkPeer(state.PeerCertificates, host, time.Now(), nil) }, From 2983f169a205b9545d37f55571011e52f6eb476a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=A0=E4=B9=A0?= <25678988+xfxx2022@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:49:57 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(security):=20codeql=20=E6=8A=91?= =?UTF-8?q?=E5=88=B6=E6=B3=A8=E9=87=8A=E6=94=B9=E4=B8=BA=E7=8B=AC=E5=8D=A0?= =?UTF-8?q?=E8=A1=8C=E7=B4=A7=E9=82=BB=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首次提交把 // codeql[...] 写在告警行行尾,实测未被 CodeQL 识别(行尾追加无效), 且带 -- 说明后缀不符合抑制注释的解析要求。现改为独占一行、紧邻告警行上方、 不含任何后缀的规范形式;解释性文字移到相邻普通注释行。 --- internal/ctyun/auth/clink_login.go | 4 +++- internal/ctyun/auth/sign.go | 4 +++- internal/ctyun/clink/tls.go | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/ctyun/auth/clink_login.go b/internal/ctyun/auth/clink_login.go index cfbac53..457da75 100644 --- a/internal/ctyun/auth/clink_login.go +++ b/internal/ctyun/auth/clink_login.go @@ -102,7 +102,9 @@ func (c *Client) LegacyClinkHeaders() (http.Header, error) { timestamp := strconv.FormatInt(c.now().UnixMilli(), 10) source := identity.DeviceType + timestamp + strconv.FormatInt(profile.TenantID, 10) + timestamp + strconv.FormatInt(profile.UserID, 10) + identity.Version + profile.SecretKey - digest := md5.Sum([]byte(source)) // codeql[go/weak-sensitive-data-hashing] -- 天翼旧 Clink 协议固定使用 MD5 签名,客户端无法更换算法 + // 抑制说明:天翼旧 Clink 鉴权协议固定使用 MD5 签名,客户端无法更换算法。 + // codeql[go/weak-sensitive-data-hashing] + digest := md5.Sum([]byte(source)) headers := c.legacyClinkBaseHeaders() headers.Set("ctg-userid", strconv.FormatInt(profile.UserID, 10)) diff --git a/internal/ctyun/auth/sign.go b/internal/ctyun/auth/sign.go index 556be6f..0fbed47 100644 --- a/internal/ctyun/auth/sign.go +++ b/internal/ctyun/auth/sign.go @@ -10,7 +10,9 @@ import ( // 与天翼登录协议同时使用。它不承担任何本地口令存储职责:登录流程只把结果作为 // 一次性传输摘要提交给服务端,算法由天翼服务端固定要求,客户端无权更换。 func SHA256Hex(value string) string { - digest := sha256.Sum256([]byte(value)) // codeql[go/weak-sensitive-data-hashing] -- 协议规定的传输摘要,非本地口令存储 + // 抑制说明:SHA256 在此是协议规定的传输摘要(含请求签名用途),不是口令存储。 + // codeql[go/weak-sensitive-data-hashing] + digest := sha256.Sum256([]byte(value)) return hex.EncodeToString(digest[:]) } diff --git a/internal/ctyun/clink/tls.go b/internal/ctyun/clink/tls.go index 8b92c2c..e18c61b 100644 --- a/internal/ctyun/clink/tls.go +++ b/internal/ctyun/clink/tls.go @@ -22,7 +22,8 @@ func newClinkTLSConfig(endpoint string) *tls.Config { // 才能在 VerifyConnection 里复刻服务端要求的兼容策略。真正的校验逻辑 // 见 verifyClinkPeer:证书链签名、ctyun.cn 域名归属、以及“尚未生效的 // 证书一律拒绝”都被显式检查,只有“已过期”这一项被兼容性放宽。 - // codeql[go/disabled-certificate-check] -- 定点抑制,理由见上 + // + // codeql[go/disabled-certificate-check] InsecureSkipVerify: true, //nolint:gosec -- 自定义校验为强制路径 VerifyConnection: func(state tls.ConnectionState) error { return verifyClinkPeer(state.PeerCertificates, host, time.Now(), nil) From 881298caaa92cdf352dfa4bd1757d6b21c1afa81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B9=A0=E4=B9=A0?= <25678988+xfxx2022@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:34:09 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=85=91=E6=8D=A2?= =?UTF-8?q?=E5=AE=88=E9=97=A8=E3=80=81=E9=85=8D=E7=BD=AE=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E4=B8=8E=20UI=20=E5=9B=9E=E8=B0=83=E7=AD=89=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 兑换守门统一:自动调度在“使用1小时”等待超时后同样跳过兑换, 不再出现未达成条件仍消耗积分下单的情况(行为变更,见 PR 说明) - storage 新增 UpdateConfig 原子读改写,登录/设置/兑换三处配置保存 不再互相覆盖;兑换设置保存的目录加载移出写锁,不再阻塞定时任务 - 登录/绑定/兑换设置对话框关闭后,丢弃仍在飞行中的网络回调, 避免回调触碰已销毁的控件 - 主日志初始化失败改为降级运行,不再阻止保活程序启动 -其余:模型状态新增 RedeemPending 字段替代文案字符串判断;随机串 改用拒绝采样消除模偏差;受保护文件名校验与 statePath 统一; 凭据写入后清零内存明文;代理 UTF-16 解析改用 windows.UTF16PtrToString; 图标资源脚本 finally 不再覆盖原始异常 --- cmd/ctyun-helper/main_windows.go | 6 +- internal/app/automation.go | 16 +++++- internal/app/redeem_settings.go | 78 +++++++++++++++----------- internal/app/settings.go | 22 ++++---- internal/app/state.go | 1 + internal/ctyun/auth/client.go | 19 +++++-- internal/ctyun/clink/proxy_windows.go | 16 ++---- internal/ctyun/eai/chat.go | 4 +- internal/ctyun/eai/client.go | 19 +++++-- internal/storage/account.go | 11 ++-- internal/storage/config.go | 43 ++++++++++++-- internal/storage/credential_windows.go | 7 +++ internal/storage/protected.go | 12 +++- internal/winui/walk_auth_windows.go | 36 ++++++++++-- internal/winui/walk_main_windows.go | 3 +- internal/winui/walk_redeem_windows.go | 14 ++++- scripts/embed-icon-windows.ps1 | 13 ++++- 17 files changed, 223 insertions(+), 97 deletions(-) diff --git a/cmd/ctyun-helper/main_windows.go b/cmd/ctyun-helper/main_windows.go index f022e71..ff1cbe6 100644 --- a/cmd/ctyun-helper/main_windows.go +++ b/cmd/ctyun-helper/main_windows.go @@ -140,7 +140,11 @@ func buildRuntime() (*app.Runtime, error) { settings := app.NewSettingsService(paths, startup, model, pointsPolicy) logger, err := logging.New(logging.Options{Path: filepath.Join(paths.LogDir, "CtyunHelper.log")}) if err != nil { - return nil, err + // 与 crash 日志同一原则:日志不可写(目录无权限/被占用/磁盘满)不能 + // 阻止保活程序启动。Runtime 与 UI 都支持 nil Logger,此时仅失去文件 + // 日志和"日志"窗口内容,原因已写入 stderr(GUI 进程由 crash 日志接管)。 + logger = nil + fmt.Fprintf(os.Stderr, "winui: file logger unavailable, running without logs: %v\n", err) } runtime := app.NewRuntime(model, authFlow, keepalive, taskAutomation, app.RuntimeOptions{ RedeemSettings: redeemSettings, diff --git a/internal/app/automation.go b/internal/app/automation.go index 39d3125..b411b25 100644 --- a/internal/app/automation.go +++ b/internal/app/automation.go @@ -144,7 +144,8 @@ func (a *TaskAutomation) RunRedeem(ctx context.Context) error { // runRedeem 区分自动调度和用户手动检查:自动调度保留旧脚本最长 80 分钟 // 等待“使用1小时”的语义;手动点击只读取一次当前状态,未完成时立即返回, -// 避免 UI 看起来长时间卡住,同时也绝不会提前下单。 +// 避免 UI 看起来长时间卡住。两条路径都由同一个守门保护:任务未完成绝不 +// 提前下单(自动路径含 80 分钟等待超时的场景)。 func (a *TaskAutomation) runRedeem(ctx context.Context, waitUsage bool) error { a.activityMu.RLock() defer a.activityMu.RUnlock() @@ -169,8 +170,16 @@ func (a *TaskAutomation) runRedeem(ctx context.Context, waitUsage bool) error { return err } a.applyPointsSnapshot(snapshot) - if !waitUsage && snapshot.UsageTaskFound && snapshot.UsageTaskStatus != automation.TaskDone { - a.applyRedeemResult(automation.RedeemResult{SkippedReason: "使用1小时任务未完成,暂不兑换"}, nil) + // 统一守门:只要“使用1小时”任务存在且未完成,就绝不进入兑换。 + // 手动路径原本如此;自动路径在 80 分钟等待超时后同样跳过,避免 + // 未达成条件下消耗积分下单(WaitUsageAndRefresh 超时以 err=nil 返回, + // 调用方只能通过任务状态判断是否达成)。 + if snapshot.UsageTaskFound && snapshot.UsageTaskStatus != automation.TaskDone { + reason := "使用1小时任务未完成,暂不兑换" + if waitUsage { + reason = "使用1小时任务等待超时仍未完成,本次不兑换" + } + a.applyRedeemResult(automation.RedeemResult{SkippedReason: reason}, nil) return nil } } @@ -201,6 +210,7 @@ func (a *TaskAutomation) UpdateAccount(account string) { state.RedeemDesktopName = desktopName state.RedeemProductName = productName state.RedeemCostPoints = plan.CostPoints + state.RedeemPending = pending state.RedeemEnabled = a.redeemJob.Enabled() && validationErr == nil && accountMatches && !pending switch { case !a.redeemJob.Enabled(): diff --git a/internal/app/redeem_settings.go b/internal/app/redeem_settings.go index 8865d7e..716fded 100644 --- a/internal/app/redeem_settings.go +++ b/internal/app/redeem_settings.go @@ -120,24 +120,23 @@ func (s *RedeemSettingsService) Save(ctx context.Context, request SaveRedeemSett if s == nil || s.tasks == nil || s.model == nil || s.tasks.redeemJob == nil { return fmt.Errorf("app: 兑换设置依赖未初始化") } - if !s.tasks.activityMu.TryLock() { - return fmt.Errorf("app: 自动任务正在运行,暂不能修改兑换设置") - } - defer s.tasks.activityMu.Unlock() - - config, err := storage.LoadConfig(s.paths) - if err != nil { - return err - } // 关闭自动兑换是纯本地操作:即使网络不可用或已经退出账号,也必须能 // 立即关闭;其余选择保留,方便用户以后重新启用时继续编辑。 if !request.Enabled { - config.Redeem.Enabled = false - if err := storage.SaveConfig(s.paths, config); err != nil { + if !s.tasks.activityMu.TryLock() { + return fmt.Errorf("app: 自动任务正在运行,暂不能修改兑换设置") + } + defer s.tasks.activityMu.Unlock() + var saved storage.Config + if err := storage.UpdateConfig(s.paths, func(config *storage.Config) error { + config.Redeem.Enabled = false + saved = *config + return nil + }); err != nil { return err } - plan := redeemPlanFromConfig(config.Redeem) + plan := redeemPlanFromConfig(saved.Redeem) if err := s.tasks.redeemJob.UpdatePlan(plan); err != nil { return fmt.Errorf("app: 更新运行中兑换计划: %w", err) } @@ -145,6 +144,8 @@ func (s *RedeemSettingsService) Save(ctx context.Context, request SaveRedeemSett return nil } + // 目录加载是网络 IO:放在写锁之外,避免持有 activityMu 写锁期间阻塞 + // 全部定时任务(读锁)最长一次 HTTP 超时;账号状态在拿到写锁后二次校验。 state := s.model.Snapshot() if state.Account == "" || state.Connection == ConnectionAuth || state.Connection == ConnectionDeviceBind { return fmt.Errorf("app: 请先完成登录和设备绑定") @@ -180,30 +181,43 @@ func (s *RedeemSettingsService) Save(ctx context.Context, request SaveRedeemSett return err } - // pending 表示 placeOrder 是否扣分未知。此时允许“关闭”,但不允许换成 - // 另一份启用计划来绕过保护;用户应先人工核对上一笔兑换结果。 - if s.tasks.redeemJob.Snapshot().LastAttemptStatus == automation.RedeemAttemptPending && redeemIdentityChanged(config.Redeem, plan) { - return fmt.Errorf("app: 上次兑换结果仍不确定,请先关闭自动兑换并人工确认后再修改计划") + if !s.tasks.activityMu.TryLock() { + return fmt.Errorf("app: 自动任务正在运行,暂不能修改兑换设置") } + defer s.tasks.activityMu.Unlock() - config.Redeem = storage.RedeemConfig{ - Enabled: true, - Account: plan.Account, - DesktopID: plan.DesktopID, - DesktopName: plan.DesktopName, - ProductID: plan.ProductID, - ProductName: plan.ProductName, - ProductType: plan.ProductType, - CostPoints: plan.CostPoints, - MaxRedeemTimes: plan.MaxRedeemTimes, - ScheduleType: plan.ScheduleType, - IntervalDays: plan.IntervalDays, - MonthlyDays: append([]int(nil), plan.MonthlyDays...), + // 持写锁后重新校验账号状态:目录加载期间用户可能已退出登录或解绑。 + state = s.model.Snapshot() + if state.Account == "" || state.Connection == ConnectionAuth || state.Connection == ConnectionDeviceBind { + return fmt.Errorf("app: 请先完成登录和设备绑定") } - // 先持久化,再更新内存计划。SaveConfig 失败时运行中的计划完全不变; - // UpdatePlan 已在上面用同一份 plan 验证过,因此落盘成功后不会出现半更新。 - if err := storage.SaveConfig(s.paths, config); err != nil { + // pending 表示 placeOrder 是否扣分未知。此时允许“关闭”,但不允许换成 + // 另一份启用计划来绕过保护;用户应先人工核对上一笔兑换结果。 + // 先持久化,再更新内存计划。UpdateConfig 失败时运行中的计划完全不变; + // UpdatePlan 已用同一份 plan 验证过,因此落盘成功后不会出现半更新。 + var saved storage.Config + if err := storage.UpdateConfig(s.paths, func(config *storage.Config) error { + if s.tasks.redeemJob.Snapshot().LastAttemptStatus == automation.RedeemAttemptPending && redeemIdentityChanged(config.Redeem, plan) { + return fmt.Errorf("app: 上次兑换结果仍不确定,请先关闭自动兑换并人工确认后再修改计划") + } + config.Redeem = storage.RedeemConfig{ + Enabled: true, + Account: plan.Account, + DesktopID: plan.DesktopID, + DesktopName: plan.DesktopName, + ProductID: plan.ProductID, + ProductName: plan.ProductName, + ProductType: plan.ProductType, + CostPoints: plan.CostPoints, + MaxRedeemTimes: plan.MaxRedeemTimes, + ScheduleType: plan.ScheduleType, + IntervalDays: plan.IntervalDays, + MonthlyDays: append([]int(nil), plan.MonthlyDays...), + } + saved = *config + return nil + }); err != nil { return err } if err := s.tasks.redeemJob.UpdatePlan(plan); err != nil { diff --git a/internal/app/settings.go b/internal/app/settings.go index 7c83c58..fd85feb 100644 --- a/internal/app/settings.go +++ b/internal/app/settings.go @@ -54,6 +54,8 @@ func (s *SettingsService) Current() (GeneralSettings, error) { // Save 同时修改当前用户 Run 注册表和 config.json。注册表先变更;若配置 // 原子写盘失败,则恢复原启动状态。只有两边都成功后才更新进程内 Model。 +// 配置部分改走 storage.UpdateConfig:登录提交(SaveAccount)与兑换设置保存 +// 并发时,各自的字段修改不再互相覆盖。 func (s *SettingsService) Save(settings GeneralSettings) error { if s == nil || s.startup == nil || s.model == nil { return fmt.Errorf("app: 通用设置服务未初始化") @@ -66,10 +68,6 @@ func (s *SettingsService) Save(settings GeneralSettings) error { if err != nil { return err } - config, err := storage.LoadConfig(s.paths) - if err != nil { - return err - } previousStartup, err := s.startup.Enabled() if err != nil { return err @@ -81,13 +79,15 @@ func (s *SettingsService) Save(settings GeneralSettings) error { } } - config.Automation.Enabled = settings.AutomationEnabled - config.Automation.UsagePointsWindow = storage.UsagePointsWindowConfig{ - Enabled: window.Enabled, - Start: window.Start, - End: window.End, - } - if err := storage.SaveConfig(s.paths, config); err != nil { + if err := storage.UpdateConfig(s.paths, func(config *storage.Config) error { + config.Automation.Enabled = settings.AutomationEnabled + config.Automation.UsagePointsWindow = storage.UsagePointsWindowConfig{ + Enabled: window.Enabled, + Start: window.Start, + End: window.End, + } + return nil + }); err != nil { if startupChanged { if rollbackErr := s.startup.SetEnabled(previousStartup); rollbackErr != nil { return errors.Join(err, fmt.Errorf("app: 回滚登录后自启动失败: %w", rollbackErr)) diff --git a/internal/app/state.go b/internal/app/state.go index 3da8679..9d5a123 100644 --- a/internal/app/state.go +++ b/internal/app/state.go @@ -58,6 +58,7 @@ type State struct { PointsTask JobStatus RedeemTask JobStatus RedeemEnabled bool + RedeemPending bool RedeemDesktopName string RedeemProductName string RedeemCostPoints int diff --git a/internal/ctyun/auth/client.go b/internal/ctyun/auth/client.go index 6c4c234..a9da71f 100644 --- a/internal/ctyun/auth/client.go +++ b/internal/ctyun/auth/client.go @@ -213,13 +213,20 @@ func (c *Client) GetTicket(ctx context.Context, service string) (string, error) func randomAlphaNumeric(source io.Reader, length int) (string, error) { const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + // 248 = 256 - 256%62:拒绝最高位区间,消除 value%len(alphabet) 的模偏差。 + const unbiasedMax = 248 buf := make([]byte, length) - raw := make([]byte, length) - if _, err := io.ReadFull(source, raw); err != nil { - return "", fmt.Errorf("auth: 生成随机数: %w", err) - } - for i, value := range raw { - buf[i] = alphabet[int(value)%len(alphabet)] + raw := make([]byte, 1) + for i := 0; i < length; i++ { + for { + if _, err := io.ReadFull(source, raw); err != nil { + return "", fmt.Errorf("auth: 生成随机数: %w", err) + } + if int(raw[0]) < unbiasedMax { + break + } + } + buf[i] = alphabet[int(raw[0])%len(alphabet)] } return string(buf), nil } diff --git a/internal/ctyun/clink/proxy_windows.go b/internal/ctyun/clink/proxy_windows.go index f069367..0487fff 100644 --- a/internal/ctyun/clink/proxy_windows.go +++ b/internal/ctyun/clink/proxy_windows.go @@ -7,6 +7,8 @@ import ( "net/url" "syscall" "unsafe" + + "golang.org/x/sys/windows" ) var ( @@ -58,16 +60,6 @@ func freeProxyString(value *uint16) { } func utf16ProxyString(value *uint16) string { - if value == nil { - return "" - } - units := make([]uint16, 0, 64) - for offset := uintptr(0); ; offset += 2 { - unit := *(*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(value)) + offset)) - if unit == 0 { - break - } - units = append(units, unit) - } - return syscall.UTF16ToString(units) + // x/sys 的实现带长度上限且处理了 nil 指针,替代原先无上界的手写遍历。 + return windows.UTF16PtrToString(value) } diff --git a/internal/ctyun/eai/chat.go b/internal/ctyun/eai/chat.go index 633071f..b9af31b 100644 --- a/internal/ctyun/eai/chat.go +++ b/internal/ctyun/eai/chat.go @@ -55,7 +55,9 @@ func (c *Client) QueryModels(ctx context.Context) ([]Model, error) { func ChooseModel(values []Model) (string, error) { available := make([]Model, 0, len(values)) for _, value := range values { - if value.KeyModel != "" && strings.EqualFold(value.Status, "avaiable") { + // 服务端历史上把 available 拼写为 avaiable;两种拼写同时接受, + // 避免服务端修正拼写后可用列表被清空、退化到任取第一个模型。 + if value.KeyModel != "" && (strings.EqualFold(value.Status, "avaiable") || strings.EqualFold(value.Status, "available")) { available = append(available, value) } } diff --git a/internal/ctyun/eai/client.go b/internal/ctyun/eai/client.go index c94962c..c9082b3 100644 --- a/internal/ctyun/eai/client.go +++ b/internal/ctyun/eai/client.go @@ -318,13 +318,20 @@ func resultError(action string, envelope rawEnvelope) error { func randomAlphaNumeric(source io.Reader, length int) (string, error) { const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - raw := make([]byte, length) - if _, err := io.ReadFull(source, raw); err != nil { - return "", fmt.Errorf("eai: 生成随机数: %w", err) - } + // 248 = 256 - 256%62:拒绝最高位区间,消除 value%len(alphabet) 的模偏差。 + const unbiasedMax = 248 result := make([]byte, length) - for i, value := range raw { - result[i] = alphabet[int(value)%len(alphabet)] + raw := make([]byte, 1) + for i := 0; i < length; i++ { + for { + if _, err := io.ReadFull(source, raw); err != nil { + return "", fmt.Errorf("eai: 生成随机数: %w", err) + } + if int(raw[0]) < unbiasedMax { + break + } + } + result[i] = alphabet[int(raw[0])%len(alphabet)] } return string(result), nil } diff --git a/internal/storage/account.go b/internal/storage/account.go index 7c2baf5..7bc74e6 100644 --- a/internal/storage/account.go +++ b/internal/storage/account.go @@ -20,12 +20,11 @@ func NewAccountStore(paths Paths) *AccountStore { } func (s *AccountStore) SaveAccount(account string) error { - config, err := LoadConfig(s.paths) - if err != nil { - return err - } - config.Account = account - return SaveConfig(s.paths, config) + // 走原子读-改-写:登录提交与设置/兑换保存并发时,各自的字段修改不再互相覆盖。 + return UpdateConfig(s.paths, func(config *Config) error { + config.Account = account + return nil + }) } func (s *AccountStore) SaveLogin(account, password string) error { diff --git a/internal/storage/config.go b/internal/storage/config.go index 6253828..4c254f5 100644 --- a/internal/storage/config.go +++ b/internal/storage/config.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "sync" "time" ) @@ -107,6 +108,29 @@ func SaveConfig(paths Paths, config Config) error { return writeAtomic(filepath.Join(paths.ConfigDir, "config.json"), raw, 0o600) } +// configMu 在进程内串行化 config.json 的读-改-写。LoadConfig/SaveConfig 各自 +// 无锁,调用方各自 Load→改→Save 会互相覆盖(例如登录提交与设置保存并发), +// 统一改走 UpdateConfig 即可消除丢失更新。 +var configMu sync.Mutex + +// UpdateConfig 在进程内互斥下执行 Load → mutate → Save 的原子配置更新。 +// mutate 返回错误时不写盘、配置保持原值。调用方需要拿到更新后的完整配置时, +// 可在 mutate 内复制 *Config 到闭包外变量。 +// 注意锁序:调用方如已持有 activityMu 等业务锁,必须先于 configMu 获取, +// 本函数绝不反向调用业务层。 +func UpdateConfig(paths Paths, mutate func(*Config) error) error { + configMu.Lock() + defer configMu.Unlock() + config, err := LoadConfig(paths) + if err != nil { + return err + } + if err := mutate(&config); err != nil { + return err + } + return SaveConfig(paths, config) +} + // EnsureWindowsDevice 只在首次没有 DeviceCode 时生成官方 Windows 新安装形态 ctyun_<32 chars>。 // 已存在 Code 时永远不替换;其余描述字段缺失只补默认值,不改变设备身份。 func EnsureWindowsDevice(config *Config, source io.Reader, now func() time.Time) (bool, error) { @@ -149,13 +173,20 @@ func EnsureWindowsDevice(config *Config, source io.Reader, now func() time.Time) func randomAlphaNumeric(source io.Reader, length int) (string, error) { const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - raw := make([]byte, length) - if _, err := io.ReadFull(source, raw); err != nil { - return "", fmt.Errorf("storage: 生成 DeviceCode: %w", err) - } + // 248 = 256 - 256%62:拒绝最高位区间,消除 value%len(alphabet) 的模偏差。 + const unbiasedMax = 248 result := make([]byte, length) - for i, value := range raw { - result[i] = alphabet[int(value)%len(alphabet)] + raw := make([]byte, 1) + for i := 0; i < length; i++ { + for { + if _, err := io.ReadFull(source, raw); err != nil { + return "", fmt.Errorf("storage: 生成 DeviceCode: %w", err) + } + if int(raw[0]) < unbiasedMax { + break + } + } + result[i] = alphabet[int(raw[0])%len(alphabet)] } return string(result), nil } diff --git a/internal/storage/credential_windows.go b/internal/storage/credential_windows.go index 3949f25..4d6dd28 100644 --- a/internal/storage/credential_windows.go +++ b/internal/storage/credential_windows.go @@ -50,6 +50,13 @@ func SaveCredential(target, username, password string) error { return err } blob := []byte(password) + // 提交到 Credential Manager 后立即清零内存中的明文副本,缩短口令在 + // 堆上可被内存扫描的窗口(读取路径返回的 string 无法清除,不受影响)。 + defer func() { + for i := range blob { + blob[i] = 0 + } + }() value := credential{ Type: credTypeGeneric, TargetName: targetPtr, diff --git a/internal/storage/protected.go b/internal/storage/protected.go index f5b850a..c2501d1 100644 --- a/internal/storage/protected.go +++ b/internal/storage/protected.go @@ -7,8 +7,14 @@ import ( "path/filepath" ) +// validProtectedName 与 statePath 的校验规则保持一致:仅允许当前目录下的 +// 普通文件名,拒绝空串、路径分隔符与 "."/".." 这类特殊目录引用。 +func validProtectedName(name string) bool { + return name != "" && filepath.Base(name) == name && name != "." && name != ".." +} + func SaveProtectedJSON(paths Paths, name string, value any) error { - if name == "" || filepath.Base(name) != name { + if !validProtectedName(name) { return fmt.Errorf("storage: 非法受保护文件名") } raw, err := json.Marshal(value) @@ -24,7 +30,7 @@ func SaveProtectedJSON(paths Paths, name string, value any) error { } func LoadProtectedJSON(paths Paths, name string, destination any) error { - if name == "" || filepath.Base(name) != name { + if !validProtectedName(name) { return fmt.Errorf("storage: 非法受保护文件名") } raw, err := os.ReadFile(filepath.Join(paths.DataDir, name)) @@ -43,7 +49,7 @@ func LoadProtectedJSON(paths Paths, name string, destination any) error { } func DeleteProtected(paths Paths, name string) error { - if name == "" || filepath.Base(name) != name { + if !validProtectedName(name) { return fmt.Errorf("storage: 非法受保护文件名") } err := os.Remove(filepath.Join(paths.DataDir, name)) diff --git a/internal/winui/walk_auth_windows.go b/internal/winui/walk_auth_windows.go index 6c5667d..84829e0 100644 --- a/internal/winui/walk_auth_windows.go +++ b/internal/winui/walk_auth_windows.go @@ -29,7 +29,10 @@ func (v *walkMainView) openLogin() { captchaAccount string captchaBitmap *walk.Bitmap busy bool - needsBinding bool + // closed 只在 UI 线程读写:dlg.Run 返回后置位,用于丢弃仍在飞行中的 + // 网络回调,避免它们继续触碰已销毁的对话框与控件。 + closed bool + needsBinding bool ) setBusy := func(value bool) { @@ -44,7 +47,7 @@ func (v *walkMainView) openLogin() { var loadCaptcha func() loadCaptcha = func() { - if busy { + if busy || closed { return } account := strings.TrimSpace(accountEdit.Text()) @@ -64,6 +67,9 @@ func (v *walkMainView) openLogin() { captcha, err := v.runtime.BeginLoginCaptcha(ctx, account) walk.App().Synchronize(func() { defer logging.RecoverPanic("winui.login_captcha_ui") + if closed { + return + } setBusy(false) if err != nil { walk.MsgBox(dlg, "验证码", err.Error(), walk.MsgBoxIconError|walk.MsgBoxOK) @@ -82,7 +88,7 @@ func (v *walkMainView) openLogin() { } submit := func() { - if busy { + if busy || closed { return } account := strings.TrimSpace(accountEdit.Text()) @@ -113,6 +119,9 @@ func (v *walkMainView) openLogin() { profile, err := v.runtime.CompleteLogin(ctx, account, password, captchaCode, captchaKey) walk.App().Synchronize(func() { defer logging.RecoverPanic("winui.login_ui") + if closed { + return + } setBusy(false) if err != nil { if auth.RequiresLoginCaptcha(err) { @@ -188,6 +197,8 @@ func (v *walkMainView) openLogin() { } dlg.Run() + // 对话框已关闭:丢弃仍在飞行中的网络回调,避免继续触碰已销毁的控件。 + closed = true if needsBinding { v.openBinding() } @@ -207,6 +218,8 @@ func (v *walkMainView) openBinding() { captchaKey string smsKey string busy bool + // 与登录对话框一致:dlg.Run 返回后丢弃仍在飞行中的网络回调。 + closed bool ) setBusy := func(value bool) { @@ -220,7 +233,7 @@ func (v *walkMainView) openBinding() { } loadChallenge := func() { - if busy { + if busy || closed { return } setBusy(true) @@ -232,6 +245,9 @@ func (v *walkMainView) openBinding() { challenge, err := v.runtime.BeginDeviceBinding(ctx) walk.App().Synchronize(func() { defer logging.RecoverPanic("winui.device_challenge_ui") + if closed { + return + } setBusy(false) if err != nil { walk.MsgBox(dlg, "设备绑定", err.Error(), walk.MsgBoxIconError|walk.MsgBoxOK) @@ -247,7 +263,7 @@ func (v *walkMainView) openBinding() { } sendSMS := func() { - if busy { + if busy || closed { return } code := strings.TrimSpace(captchaEdit.Text()) @@ -263,6 +279,9 @@ func (v *walkMainView) openBinding() { newSMSKey, err := v.runtime.SendDeviceSMS(ctx, code, key) walk.App().Synchronize(func() { defer logging.RecoverPanic("winui.device_sms_ui") + if closed { + return + } setBusy(false) if err != nil { walk.MsgBox(dlg, "发送短信失败", err.Error(), walk.MsgBoxIconError|walk.MsgBoxOK) @@ -276,7 +295,7 @@ func (v *walkMainView) openBinding() { } complete := func() { - if busy { + if busy || closed { return } code := strings.TrimSpace(smsEdit.Text()) @@ -292,6 +311,9 @@ func (v *walkMainView) openBinding() { err := v.runtime.CompleteDeviceBinding(ctx, code, key) walk.App().Synchronize(func() { defer logging.RecoverPanic("winui.device_binding_ui") + if closed { + return + } setBusy(false) if err != nil { walk.MsgBox(dlg, "设备绑定失败", err.Error(), walk.MsgBoxIconError|walk.MsgBoxOK) @@ -351,6 +373,8 @@ func (v *walkMainView) openBinding() { loadChallenge() dlg.Run() + // 对话框已关闭:丢弃仍在飞行中的网络回调,避免继续触碰已销毁的控件。 + closed = true } func setWalkCaptchaImage(view *walk.ImageView, raw []byte, current **walk.Bitmap) error { diff --git a/internal/winui/walk_main_windows.go b/internal/winui/walk_main_windows.go index 2337f81..dd1d11f 100644 --- a/internal/winui/walk_main_windows.go +++ b/internal/winui/walk_main_windows.go @@ -431,7 +431,8 @@ func setWalkText(label *walk.TextLabel, text string) { } func redeemNextRunText(state app.State) string { - if state.RedeemSummary == "上次兑换结果不确定,已停止自动兑换" { + // 用结构化字段而非文案字符串判断,避免提示语调整时悄悄失效。 + if state.RedeemPending { return "待确认上一笔" } if !state.RedeemEnabled { diff --git a/internal/winui/walk_redeem_windows.go b/internal/winui/walk_redeem_windows.go index 7682ac3..b36c9e0 100644 --- a/internal/winui/walk_redeem_windows.go +++ b/internal/winui/walk_redeem_windows.go @@ -40,6 +40,8 @@ func (v *walkMainView) openRedeemSettings() { catalog app.RedeemCatalog catalogLoaded bool busy bool + // dlg.Run 返回后置位:丢弃仍在飞行中的网络回调,避免触碰已销毁控件。 + closed bool ) setBusy := func(value bool) { @@ -73,7 +75,7 @@ func (v *walkMainView) openRedeemSettings() { var loadCatalog func() loadCatalog = func() { - if busy { + if busy || closed { return } setBusy(true) @@ -85,6 +87,9 @@ func (v *walkMainView) openRedeemSettings() { newCatalog, err := v.runtime.LoadRedeemCatalog(ctx) walk.App().Synchronize(func() { defer logging.RecoverPanic("winui.redeem_catalog_ui") + if closed { + return + } setBusy(false) if err != nil { catalogLoaded = false @@ -111,7 +116,7 @@ func (v *walkMainView) openRedeemSettings() { } save := func() { - if busy { + if busy || closed { return } request := app.SaveRedeemSettingsRequest{Enabled: enabledCheck.Checked()} @@ -159,6 +164,9 @@ func (v *walkMainView) openRedeemSettings() { err := v.runtime.SaveRedeemSettings(ctx, request) walk.App().Synchronize(func() { defer logging.RecoverPanic("winui.redeem_save_ui") + if closed { + return + } setBusy(false) if err != nil { _ = statusLabel.SetText("保存失败。") @@ -276,6 +284,8 @@ func (v *walkMainView) openRedeemSettings() { _ = scheduleCombo.SetCurrentIndex(scheduleIndex) loadCatalog() dlg.Run() + // 对话框已关闭:丢弃仍在飞行中的网络回调,避免继续触碰已销毁的控件。 + closed = true } func parseWalkMonthlyDays(text string) ([]int, error) { diff --git a/scripts/embed-icon-windows.ps1 b/scripts/embed-icon-windows.ps1 index 02ebc5d..9fad124 100644 --- a/scripts/embed-icon-windows.ps1 +++ b/scripts/embed-icon-windows.ps1 @@ -68,6 +68,7 @@ if ($update -eq [IntPtr]::Zero) { } $commit = $false +$originalError = $null try { for ($i = 0; $i -lt $count; $i++) { $icoOffset = 6 + 16 * $i @@ -106,9 +107,19 @@ try { } $commit = $true } +catch { + # 记住 try 内的原始异常:finally 里的 EndUpdateResource 失败只作警告, + # 不能覆盖根因(PowerShell 中 finally 抛异常会取代原异常)。 + $originalError = $_ + throw +} finally { if (-not [CtyunHelper.WinResourceNative]::EndUpdateResource($update, -not $commit)) { - throw "EndUpdateResource failed. Win32=$([Runtime.InteropServices.Marshal]::GetLastWin32Error())" + $endError = "EndUpdateResource failed. Win32=$([Runtime.InteropServices.Marshal]::GetLastWin32Error())" + if ($null -eq $originalError) { + throw $endError + } + Write-Warning $endError } }