feat(license): dual-path opsx-licence client alongside HMAC

Wire LIC-/LICOFF- activate/verify/heartbeat to licence.rlwj.cn/api,
keep legacy HMAC accepted with deprecation banner, add instance_id
fingerprint and licensing docs (product lokistack still needs admin create).
This commit is contained in:
opsx committed 2026-11-01 02:55:37 +08:00
1 parent 5f42d9f696
commit af0298e994
13 files changed
+888 -37

No files matched your search

+184 -10
View File
@@ -1,8 +1,9 @@
package services
// Pro 授权:离线许可码(HMAC-SHA256 签名,零外部依赖)。
// 发行:`lokistack license --plan=pro --edition=team ...`(需发行密钥,见 licenseKeyOverride);
// 激活:设置页输入许可码 → 验签落库;无有效许可 = 免费版(查询/部署/用户/LDAP 全可用)。
// 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"
@@ -32,7 +33,7 @@ type licensePayload struct {
IssuedAt int64 `json:"issuedAt"`
}
// GenerateLicenseKey 签发许可码(发行方工具;days=0 表示永久)
// GenerateLicenseKey 签发许可码(发行方工具;days=0 表示永久) — 旧 HMAC 路径,迁移期保留。
func GenerateLicenseKey(plan, edition, customer string, seats, instances, days int) (string, error) {
if plan != "pro" {
return "", fmt.Errorf("许可码只用于签发 pro;免费版无需许可")
@@ -54,7 +55,7 @@ func GenerateLicenseKey(plan, edition, customer string, seats, instances, days i
return base64.RawURLEncoding.EncodeToString(append(sig[:16], body...)), nil
}
// VerifyLicenseKey 校验许可码,返回许可内容;伪造/篡改/过期均报错
// VerifyLicenseKey 校验旧 HMAC 许可码,返回许可内容;伪造/篡改/过期均报错
func VerifyLicenseKey(key string) (*models.License, error) {
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(key))
if err != nil || len(raw) <= 16 {
@@ -80,15 +81,112 @@ func VerifyLicenseKey(key string) (*models.License, error) {
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
}
// ActivateLicense 激活(验签后落库,覆盖旧许可)
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 = ""
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,
}
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.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
@@ -97,19 +195,95 @@ func ActivateLicense(key string) (*models.License, error) {
return lic, nil
}
// RefreshOpsxLicenseIfDue 周检:距上次验证超过 7 天则联网 verify;网络失败保留 30 天离线宽限。
// 服务端明确拒绝则降级(清 activated);旧 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:") {
// 明确拒绝:降级
db.Lock()
if db.License != nil {
db.License.Plan = "free"
db.License.DeprecationNotice = strings.TrimPrefix(msg, "rejected:")
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)
}
// CurrentLicense 当前有效许可(nil = 免费版)
func CurrentLicense() *models.License {
db := repository.GetDB()
if db == nil || db.License == nil {
return nil
}
if db.License.Plan != "pro" {
lic := db.License
if lic.Plan != "pro" {
return nil
}
if db.License.ExpiresAt > 0 && db.License.ExpiresAt < time.Now().Unix() {
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
}
}
return db.License
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