Files
LokiStack/backend/services/license.go
T
opsx 62d9b81a70 fix(license): shared invalid/grace banners + instance copy
Surface expired/over_instance/revoked red banners with CTAs via a
shared LicenseStatusBanners used by settings and ProGate. Hide seats
in product UI; keep HMAC dual-path and never silent-clear keys.
2026-11-01 03:08:37 +08:00

375 lines
11 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)
}
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。
func LicenseStatusPayload() (map[string]interface{}, bool) {
RefreshOpsxLicenseIfDue()
current := CurrentLicense()
stored := StoredLicense()
if stored == nil {
return map[string]interface{}{"plan": "free"}, false
}
out := stored.Sanitized()
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 }