Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/ctyun-helper/main_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 18 additions & 4 deletions internal/app/automation.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"sync"

"github.com/uvwt/CtyunHelper/internal/automation"
"github.com/uvwt/CtyunHelper/internal/logging"
)

const (
Expand Down Expand Up @@ -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)
}()
}
}

Expand Down Expand Up @@ -140,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()
Expand All @@ -165,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
}
}
Expand Down Expand Up @@ -197,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():
Expand Down
78 changes: 46 additions & 32 deletions internal/app/redeem_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,31 +120,32 @@ 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)
}
s.tasks.UpdateAccount(s.model.Snapshot().Account)
return nil
}

// 目录加载是网络 IO:放在写锁之外,避免持有 activityMu 写锁期间阻塞
// 全部定时任务(读锁)最长一次 HTTP 超时;账号状态在拿到写锁后二次校验。
state := s.model.Snapshot()
if state.Account == "" || state.Connection == ConnectionAuth || state.Connection == ConnectionDeviceBind {
return fmt.Errorf("app: 请先完成登录和设备绑定")
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion internal/app/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 11 additions & 11 deletions internal/app/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: 通用设置服务未初始化")
Expand All @@ -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
Expand All @@ -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))
Expand Down
1 change: 1 addition & 0 deletions internal/app/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type State struct {
PointsTask JobStatus
RedeemTask JobStatus
RedeemEnabled bool
RedeemPending bool
RedeemDesktopName string
RedeemProductName string
RedeemCostPoints int
Expand Down
14 changes: 10 additions & 4 deletions internal/automation/redeem_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
19 changes: 13 additions & 6 deletions internal/ctyun/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions internal/ctyun/auth/clink_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ 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
// 抑制说明:天翼旧 Clink 鉴权协议固定使用 MD5 签名,客户端无法更换算法。
// codeql[go/weak-sensitive-data-hashing]
digest := md5.Sum([]byte(source))

headers := c.legacyClinkBaseHeaders()
Expand Down
7 changes: 7 additions & 0 deletions internal/ctyun/auth/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ import (
"strings"
)

// SHA256Hex 是通用摘要工具,被请求签名(PublicSignature/ServerNodeSignature)
// 与天翼登录协议同时使用。它不承担任何本地口令存储职责:登录流程只把结果作为
// 一次性传输摘要提交给服务端,算法由天翼服务端固定要求,客户端无权更换。
func SHA256Hex(value string) string {
// 抑制说明:SHA256 在此是协议规定的传输摘要(含请求签名用途),不是口令存储。
// codeql[go/weak-sensitive-data-hashing]
digest := sha256.Sum256([]byte(value))
return hex.EncodeToString(digest[:])
}

// LoginPassword 复现天翼官方客户端的口令摘要:sha256(sha256(password) + challengeCode)。
// 服务端按该固定算法校验,换成任何更“强”的算法都会导致登录失败。
func LoginPassword(password, challengeCode string) string {
return SHA256Hex(SHA256Hex(password) + challengeCode)
}
Expand Down
11 changes: 11 additions & 0 deletions internal/ctyun/clink/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,24 @@ 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 {
extra := 0
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)))
Expand Down
16 changes: 4 additions & 12 deletions internal/ctyun/clink/proxy_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"net/url"
"syscall"
"unsafe"

"golang.org/x/sys/windows"
)

var (
Expand Down Expand Up @@ -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)
}
13 changes: 11 additions & 2 deletions internal/ctyun/clink/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,17 @@ 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)
},
Expand Down
Loading