package services // Pro 授权双路径: // 1) opsx-licence: LIC- / LICOFF- → activate|verify|heartbeat(默认 https://licence.rlwj.cn/api) // 2) 旧 HMAC 本地码: 迁移窗口内仍接受,并附带 DeprecationNotice(禁止静默失效) // 发行旧码: `lokistack license --plan=pro ...`(见 licenseIssueKey);新码由授权后台签发。 import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "strings" "time" "lokistack/models" "lokistack/repository" ) // licenseIssueKey 发行密钥(正式发行用 -ldflags 覆盖;默认值为开发/试用签名) var licenseIssueKey = "lokistack-issue-key-override-in-release-build" // Pro 功能清单(免费版可用功能见 router 的 RequirePro 挂载点) var proFeatures = []string{"log_analysis", "log_alerts", "ai_ops", "audit_log"} type licensePayload struct { Plan string `json:"plan"` Edition string `json:"edition"` Seats int `json:"seats"` Instances int `json:"instances"` Customer string `json:"customer"` ExpiresAt int64 `json:"expiresAt"` IssuedAt int64 `json:"issuedAt"` } // GenerateLicenseKey 签发许可码(发行方工具;days=0 表示永久) — 旧 HMAC 路径,迁移期保留。 func GenerateLicenseKey(plan, edition, customer string, seats, instances, days int) (string, error) { if plan != "pro" { return "", fmt.Errorf("许可码只用于签发 pro;免费版无需许可") } if edition != "personal" && edition != "team" { return "", fmt.Errorf("edition 仅支持 personal / team") } p := licensePayload{ Plan: plan, Edition: edition, Seats: seats, Instances: instances, Customer: customer, IssuedAt: time.Now().Unix(), } if days > 0 { p.ExpiresAt = time.Now().AddDate(0, 0, days).Unix() } body, _ := json.Marshal(p) mac := hmac.New(sha256.New, []byte(licenseIssueKey)) mac.Write(body) sig := mac.Sum(nil) return base64.RawURLEncoding.EncodeToString(append(sig[:16], body...)), nil } // VerifyLicenseKey 校验旧 HMAC 许可码,返回许可内容;伪造/篡改/过期均报错 func VerifyLicenseKey(key string) (*models.License, error) { raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(key)) if err != nil || len(raw) <= 16 { return nil, fmt.Errorf("许可码格式无效") } sig, body := raw[:16], raw[16:] mac := hmac.New(sha256.New, []byte(licenseIssueKey)) mac.Write(body) if !hmac.Equal(sig, mac.Sum(nil)[:16]) { return nil, fmt.Errorf("许可码签名校验失败(伪造或被篡改)") } var p licensePayload if err := json.Unmarshal(body, &p); err != nil { return nil, fmt.Errorf("许可码内容无效") } if p.Plan != "pro" { return nil, fmt.Errorf("许可码不是 Pro 版") } if p.ExpiresAt > 0 && p.ExpiresAt < time.Now().Unix() { return nil, fmt.Errorf("许可码已过期") } return &models.License{ Plan: p.Plan, Edition: p.Edition, Seats: p.Seats, Instances: p.Instances, Customer: p.Customer, ExpiresAt: p.ExpiresAt, IssuedAt: time.Unix(p.IssuedAt, 0), ActivatedAt: time.Now(), Source: "hmac", DeprecationNotice: hmacDeprecationNotice, }, nil } func editionFromPlanType(planType string, maxDevices int) string { if planType == "trial" || maxDevices <= 1 { return "personal" } return "team" } func applyRemoteInfo(lic *models.License, key string, instanceID string, info *licenceRemoteInfo) { lic.Plan = "pro" lic.Source = "opsx" lic.LicenseKey = key lic.InstanceID = instanceID lic.PlanType = info.PlanType lic.MaxDevices = info.MaxDevices if info.MaxDevices > 0 { lic.Instances = info.MaxDevices } lic.Edition = editionFromPlanType(info.PlanType, info.MaxDevices) lic.ExpiresAt = info.ExpiresAtUnix lic.InGrace = info.InGrace lic.GraceDaysLeft = info.GraceDaysLeft lic.LastVerifiedAt = time.Now().Unix() if info.ServerTime > 0 { lic.LastServerTime = info.ServerTime } if info.ActivationToken != "" { lic.ActivationToken = info.ActivationToken } lic.DeprecationNotice = "" lic.InvalidReason = "" if lic.ActivatedAt.IsZero() { lic.ActivatedAt = time.Now() } if lic.IssuedAt.IsZero() { lic.IssuedAt = time.Now() } } // ActivateLicense 激活: LIC-/LICOFF- 走 opsx-licence;其余走旧 HMAC(带迁移提示)。 func ActivateLicense(key string) (*models.License, error) { key = strings.TrimSpace(key) if key == "" { return nil, fmt.Errorf("请输入许可码") } instanceID := GetOrCreateInstanceID() if isOfflineLicenseKey(key) { claims, err := verifyOfflineSerial(key, instanceID) if err != nil { return nil, err } lic := &models.License{ Plan: "pro", Source: "opsx", LicenseKey: key, InstanceID: instanceID, PlanType: claims.PlanType, MaxDevices: claims.MaxDevices, Instances: claims.MaxDevices, Edition: editionFromPlanType(claims.PlanType, claims.MaxDevices), ExpiresAt: claims.Exp, IssuedAt: time.Unix(claims.Iat, 0), ActivatedAt: time.Now(), LastVerifiedAt: time.Now().Unix(), Customer: claims.Email, InvalidReason: "", } return persistLicense(lic) } if isOpsxLicenseKey(key) { info, err := remoteActivateLicence(key, instanceID) if err != nil { return nil, err } // 激活后再 verify 一次拿齐 expires/plan if v, vErr := remoteVerifyLicence(key, instanceID); vErr == nil { if v.ExpiresAtUnix > 0 { info.ExpiresAtUnix = v.ExpiresAtUnix } if v.PlanType != "" { info.PlanType = v.PlanType } if v.MaxDevices > 0 { info.MaxDevices = v.MaxDevices } info.InGrace = v.InGrace info.GraceDaysLeft = v.GraceDaysLeft if v.ServerTime > 0 { info.ServerTime = v.ServerTime } } lic := &models.License{ActivatedAt: time.Now(), IssuedAt: time.Now()} applyRemoteInfo(lic, key, instanceID, info) _ = remoteHeartbeatLicence(key, instanceID) // best-effort return persistLicense(lic) } // 旧 HMAC 路径:迁移窗口内保留 lic, err := VerifyLicenseKey(key) if err != nil { return nil, err } lic.Source = "hmac" lic.DeprecationNotice = hmacDeprecationNotice lic.InvalidReason = "" lic.InstanceID = instanceID lic.LicenseKey = key lic.LastVerifiedAt = time.Now().Unix() return persistLicense(lic) } // DeactivateResult 退出授权结果。OfflineLocalOnly 表示仅解本机(LICOFF-),后台名额可能仍占。 type DeactivateResult struct { OfflineLocalOnly bool `json:"offlineLocalOnly"` Message string `json:"message,omitempty"` } // DeactivateLicense 用户主动退出授权 / 释放本实例。 // - 在线 LIC-:先 POST /v1/license/deactivate(带 activation_token),成功才清本地;失败保留现状。 // - 离线 LICOFF-:只解本机绑定,并提示后台名额可能仍占。 // - 旧 HMAC:仅清本地(无远端座位)。 // 禁止在校验失败路径静默清码;本函数仅用户确认后调用。 func DeactivateLicense() (*DeactivateResult, error) { db := repository.GetDB() if db == nil { return nil, fmt.Errorf("数据库未就绪") } db.RLock() lic := db.License db.RUnlock() if lic == nil { return nil, fmt.Errorf("当前未激活授权") } // 允许对仍存证的 Pro(含宽限)或刚降级但仍占远端席位的 opsx 证做主动释放 key := strings.TrimSpace(lic.LicenseKey) if lic.Plan != "pro" && key == "" { return nil, fmt.Errorf("当前未激活授权") } token := lic.ActivationToken instanceID := lic.InstanceID if instanceID == "" { instanceID = GetOrCreateInstanceID() } result := &DeactivateResult{} if key != "" && isOfflineLicenseKey(key) { clearStoredLicense() result.OfflineLocalOnly = true result.Message = "已解除本机离线授权。后台名额可能仍占用,如需释放请联系运营处理。" return result, nil } if key != "" && isOpsxLicenseKey(key) { if err := remoteDeactivateLicence(key, instanceID, token); err != nil { // 授权服务不返回 activation_token(激活响应无该字段),本机拿不到解绑凭证时 // 远端必然拒绝——不能把用户锁死在"永远解不了绑"里。降级为仅清本机: // 本机回免费版(用户诉求达成),远端座位由服务端到期/管理员释放。 clearStoredLicense() result.OfflineLocalOnly = true result.Message = "已退出授权,本机已回到免费版。远端席位未能自动释放(服务未下发解绑凭证),如需释放请到授权平台管理或联系运营。" return result, nil } clearStoredLicense() result.Message = "已退出授权并释放本实例" return result, nil } // HMAC 或无 key 的本地 Pro clearStoredLicense() result.Message = "已清除本机授权" return result, nil } func clearStoredLicense() { db := repository.GetDB() if db == nil { return } db.Lock() db.License = nil db.RequestSave() db.Unlock() } func persistLicense(lic *models.License) (*models.License, error) { db := repository.GetDB() db.Lock() db.License = lic db.RequestSave() db.Unlock() return lic, nil } // RefreshOpsxLicenseIfDue 周检:距上次验证超过 7 天则联网 verify;网络失败保留 30 天离线宽限。 // 服务端明确拒绝则降级为 free 并写 InvalidReason;保留 LicenseKey(禁静默清码);旧 HMAC 不联网。 func RefreshOpsxLicenseIfDue() { db := repository.GetDB() if db == nil { return } db.RLock() lic := db.License db.RUnlock() if lic == nil || lic.Source != "opsx" || lic.LicenseKey == "" { return } if isOfflineLicenseKey(lic.LicenseKey) { return // 离线证本地自验即可 } now := time.Now().Unix() if lic.LastVerifiedAt > 0 && now-lic.LastVerifiedAt < weeklyVerifySecs { return } instanceID := lic.InstanceID if instanceID == "" { instanceID = GetOrCreateInstanceID() } info, err := remoteVerifyLicence(lic.LicenseKey, instanceID) if err != nil { msg := err.Error() if strings.HasPrefix(msg, "rejected:") { // 明确拒绝:降级为 free,保留 LicenseKey(禁止静默清码) reason := classifyRejectReason(strings.TrimPrefix(msg, "rejected:")) db.Lock() if db.License != nil { db.License.Plan = "free" db.License.InvalidReason = reason db.RequestSave() } db.Unlock() return } // 网络失败:30 天离线容忍,由 CurrentLicense 判断 return } db.Lock() if db.License != nil { applyRemoteInfo(db.License, lic.LicenseKey, instanceID, info) db.RequestSave() } db.Unlock() _ = remoteHeartbeatLicence(lic.LicenseKey, instanceID) } // StoredLicense 库内原始许可(含已降级/过期记录);勿与 CurrentLicense 混淆。 func StoredLicense() *models.License { db := repository.GetDB() if db == nil { return nil } db.RLock() defer db.RUnlock() return db.License } // classifyRejectReason 把授权服务拒绝文案映射为 UI 红条类别。 func classifyRejectReason(msg string) string { lower := strings.ToLower(msg) over := (strings.Contains(lower, "device") && (strings.Contains(lower, "limit") || strings.Contains(lower, "full") || strings.Contains(lower, "exceed"))) || strings.Contains(lower, "seat") || (strings.Contains(lower, "instance") && (strings.Contains(lower, "limit") || strings.Contains(lower, "exceed") || strings.Contains(lower, "full"))) || (strings.Contains(msg, "实例") && (strings.Contains(msg, "上限") || strings.Contains(msg, "满") || strings.Contains(msg, "超"))) || (strings.Contains(msg, "席位") && (strings.Contains(msg, "满") || strings.Contains(msg, "超") || strings.Contains(msg, "上限"))) || (strings.Contains(msg, "设备") && (strings.Contains(msg, "满") || strings.Contains(msg, "超") || strings.Contains(msg, "上限"))) if over { return "over_instance" } if strings.Contains(lower, "expir") || strings.Contains(msg, "过期") { return "expired" } return "revoked" } func deriveInvalidReason(lic *models.License) string { if lic == nil { return "" } if lic.InvalidReason != "" { return lic.InvalidReason } now := time.Now().Unix() if lic.ExpiresAt > 0 && lic.ExpiresAt < now && !lic.InGrace { return "expired" } if lic.Source == "opsx" && lic.LicenseKey != "" && !isOfflineLicenseKey(lic.LicenseKey) { last := lic.LastVerifiedAt if last == 0 && !lic.ActivatedAt.IsZero() { last = lic.ActivatedAt.Unix() } if last > 0 && now-last > offlineToleranceSecs { return "expired" } } if lic.Plan == "free" && lic.LicenseKey != "" { return "revoked" } return "" } // LicenseStatusPayload 给前端:始终带回库存证的 sanitized + 是否有效 Pro。 // 无效时带 invalidReason(expired|over_instance|revoked),且不清 LicenseKey。 // instanceId 始终返回本机指纹(DATA_DIR/instance.id),供离线 LICOFF- 绑码展示。 func LicenseStatusPayload() (map[string]interface{}, bool) { RefreshOpsxLicenseIfDue() current := CurrentLicense() stored := StoredLicense() instanceID := GetOrCreateInstanceID() if stored == nil { return map[string]interface{}{"plan": "free", "instanceId": instanceID}, false } out := stored.Sanitized() out["instanceId"] = instanceID if current != nil { delete(out, "invalidReason") return out, true } reason := deriveInvalidReason(stored) if reason != "" { out["invalidReason"] = reason } out["plan"] = "free" return out, false } // CurrentLicense 当前有效许可(nil = 免费版) func CurrentLicense() *models.License { db := repository.GetDB() if db == nil || db.License == nil { return nil } lic := db.License if lic.Plan != "pro" { return nil } now := time.Now().Unix() // opsx 在线证:超过 30 天未成功联网 → 降级 if lic.Source == "opsx" && lic.LicenseKey != "" && !isOfflineLicenseKey(lic.LicenseKey) { last := lic.LastVerifiedAt if last == 0 { last = lic.ActivatedAt.Unix() } if last > 0 && now-last > offlineToleranceSecs { return nil } } if lic.ExpiresAt > 0 && lic.ExpiresAt < now { if lic.InGrace { return lic // 服务端宽限内仍可用 } return nil } // 旧 HMAC:补迁移提示(已激活历史数据) if lic.Source == "" || lic.Source == "hmac" { if lic.DeprecationNotice == "" { lic.DeprecationNotice = hmacDeprecationNotice } if lic.Source == "" { lic.Source = "hmac" } } return lic } // IsPro 是否为有效 Pro func IsPro() bool { return CurrentLicense() != nil } // ProFeatureList Pro 功能标识(给前端展示) func ProFeatureList() []string { return proFeatures }