根因(实测确认):授权服务 activate 响应不含 activation_token 字段,本机存证里 token 为空;deactivate 端要求该凭证 → 远端永远拒绝 → 用户点退出只会报 「远端解绑失败,本机授权未清除」且无法自愈。 修复:远端解绑失败时降级——清本机授权(回免费版,用户诉求达成), 返回 offlineLocalOnly 提示远端席位需服务端释放;服务端将来补发 token 后 此路径自动回到完整解绑。
452 lines
14 KiB
Go
452 lines
14 KiB
Go
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 }
|