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:
1 parent
5f42d9f696
commit
af0298e994
13 files changed
+888
-37
No files matched your search
+184
-10
@@ -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
|
||||
|
||||
Reference in new issue
Block a user