Files
opsx 9a477e9c9b
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
feat(license): 首次安装 14 天 Pro 试用(无需授权码)
- 首次启动落试用起点(DATA_DIR/.trial-start 0600 + 库内 settings 双写,取最早防分次续命);
  窗口内 IsPro() 等效 Pro——日志分析/告警/AI 助手/审计全解锁,到期自动回免费版
- 防刷三层:时间戳双写(删任一不重发)+ 机器级指纹(machine-id / hostname+cpu,
  故意不含 DATA_DIR 特征——目录特征会因删除重建漂移等于后门);指纹命中则落已过期起点
- LicenseStatusPayload 返回 trial{active,daysLeft} 与 plan=trial;
  设置页授权区显示「Pro 试用中 · 剩余 N 天」提示条
- 单测:全新安装 14 天/到期回落/删时间戳重装不重发(指纹防刷)三用例
- 滥用评估:删库重装同机器被指纹拦截;换机器/VM 必然可重试(接受,Pro 功能零边际成本,
  追杀到硬件级得不偿失);正式授权随时激活覆盖试用
2026-11-01 05:53:55 +08:00

473 lines
15 KiB
Go
Raw Permalink 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()
EnsureTrialStarted()
trialDaysLeft := 0
if TrialActive() {
trialDaysLeft = TrialRemainingDays()
}
if stored == nil {
plan := "free"
if trialDaysLeft > 0 {
plan = "trial"
}
return map[string]interface{}{
"plan": plan, "instanceId": instanceID,
"trial": map[string]interface{}{"active": trialDaysLeft > 0, "daysLeft": trialDaysLeft},
}, trialDaysLeft > 0
}
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"
if trialDaysLeft > 0 {
out["plan"] = "trial"
}
out["trial"] = map[string]interface{}{"active": trialDaysLeft > 0, "daysLeft": trialDaysLeft}
return out, trialDaysLeft > 0
}
// 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:正式授权优先;无授权时 14 天试用窗口内等效 Pro
func IsPro() bool {
if CurrentLicense() != nil {
return true
}
return TrialActive()
}
// ProFeatureList Pro 功能标识(给前端展示)
func ProFeatureList() []string { return proFeatures }