Files
LokiStack/backend/services/license.go
T
opsx daa531f4ed
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
fix(license): 退出授权在拿不到解绑凭证时降级清本机,不再卡死报错
根因(实测确认):授权服务 activate 响应不含 activation_token 字段,本机存证里
token 为空;deactivate 端要求该凭证 → 远端永远拒绝 → 用户点退出只会报
「远端解绑失败,本机授权未清除」且无法自愈。
修复:远端解绑失败时降级——清本机授权(回免费版,用户诉求达成),
返回 offlineLocalOnly 提示远端席位需服务端释放;服务端将来补发 token 后
此路径自动回到完整解绑。
2026-11-01 04:06:56 +08:00

452 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }