feat(license): 退出授权 / 释放本实例(确认后 deactivate)
设置→授权 Pro 态管理员可退出:在线 LIC- 先调 opsx deactivate(含 activation_token),成功才清本地;失败保留现状。LICOFF- 仅解本机并提示后台名额可能仍占。禁静默清码。
This commit is contained in:
1 parent
f3d27ceb04
commit
cec7c6eeaf
10 files changed
+338
-3
No files matched your search
@@ -68,3 +68,13 @@ func (ctrl *SystemController) ActivateLicense(c *gin.Context) {
|
||||
}
|
||||
utils.Success(c, lic.Sanitized())
|
||||
}
|
||||
|
||||
// DeactivateLicense 退出授权 / 释放本实例(admin);成功才清本地,失败保留现状。
|
||||
func (ctrl *SystemController) DeactivateLicense(c *gin.Context) {
|
||||
res, err := services.DeactivateLicense()
|
||||
if err != nil {
|
||||
utils.Error(c, 400, err.Error())
|
||||
return
|
||||
}
|
||||
utils.Success(c, res)
|
||||
}
|
||||
@@ -169,6 +169,7 @@ func SetupRouter() *gin.Engine {
|
||||
system.GET("/self-check", systemCtrl.Health)
|
||||
system.GET("/license", systemCtrl.LicenseStatus)
|
||||
system.POST("/license/activate", middleware.AdminAuth(), systemCtrl.ActivateLicense)
|
||||
system.POST("/license/deactivate", middleware.AdminAuth(), systemCtrl.DeactivateLicense)
|
||||
}
|
||||
|
||||
// 审计日志:仅管理员可查(含他人操作记录)
|
||||
|
||||
@@ -331,3 +331,25 @@ func remoteHeartbeatLicence(licenseKey, deviceID string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func remoteDeactivateLicence(licenseKey, deviceID, activationToken string) error {
|
||||
body := map[string]string{
|
||||
"license_key": licenseKey,
|
||||
"device_id": deviceID,
|
||||
}
|
||||
if strings.TrimSpace(activationToken) != "" {
|
||||
body["activation_token"] = strings.TrimSpace(activationToken)
|
||||
}
|
||||
env, err := postLicenceJSON("/v1/license/deactivate", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if envelopeCode(env) != 0 {
|
||||
msg := envelopeMessage(env)
|
||||
if msg == "" {
|
||||
msg = "解绑失败"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -188,6 +188,75 @@ func ActivateLicense(key string) (*models.License, error) {
|
||||
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 {
|
||||
return nil, fmt.Errorf("远端解绑失败,本机授权未清除: %w", err)
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lokistack/config"
|
||||
"lokistack/models"
|
||||
"lokistack/repository"
|
||||
)
|
||||
|
||||
func withTempDB(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
t.Setenv("JWT_SECRET", "test-jwt-secret-with-at-least-32-characters")
|
||||
t.Setenv("ENCRYPTION_KEY", "test-encryption-key-with-at-least-32-characters")
|
||||
if err := repository.InitDB(); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repository.Shutdown() })
|
||||
}
|
||||
|
||||
func TestDeactivateLicenseHMACClearsLocal(t *testing.T) {
|
||||
withTempDB(t)
|
||||
key, err := GenerateLicenseKey("pro", "personal", "c", 1, 1, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ActivateLicense(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if CurrentLicense() == nil {
|
||||
t.Fatal("expected pro before deactivate")
|
||||
}
|
||||
res, err := DeactivateLicense()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.OfflineLocalOnly {
|
||||
t.Fatal("HMAC should not be offlineLocalOnly")
|
||||
}
|
||||
if CurrentLicense() != nil || StoredLicense() != nil {
|
||||
t.Fatal("expected local license cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateLicenseOfflineLocalOnly(t *testing.T) {
|
||||
withTempDB(t)
|
||||
instanceID := GetOrCreateInstanceID()
|
||||
// 无法签发真 LICOFF- 无私钥;直接写入库存模拟离线证
|
||||
db := repository.GetDB()
|
||||
db.Lock()
|
||||
db.License = &models.License{
|
||||
Plan: "pro", Source: "opsx", LicenseKey: "LICOFF-TEST-FAKE",
|
||||
InstanceID: instanceID, ActivatedAt: time.Now(), IssuedAt: time.Now(),
|
||||
}
|
||||
db.RequestSave()
|
||||
db.Unlock()
|
||||
|
||||
res, err := DeactivateLicense()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.OfflineLocalOnly {
|
||||
t.Fatal("expected offlineLocalOnly")
|
||||
}
|
||||
if !strings.Contains(res.Message, "后台") && !strings.Contains(strings.ToLower(res.Message), "ops") {
|
||||
// 中文提示
|
||||
if res.Message == "" {
|
||||
t.Fatal("expected offline note message")
|
||||
}
|
||||
}
|
||||
if StoredLicense() != nil {
|
||||
t.Fatal("expected cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateLicenseOnlineKeepsStateOnRemoteFailure(t *testing.T) {
|
||||
withTempDB(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 1, "message": "解绑校验失败"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := config.GetConfig()
|
||||
prev := cfg.LicenseAPIBase
|
||||
cfg.LicenseAPIBase = srv.URL
|
||||
defer func() { cfg.LicenseAPIBase = prev }()
|
||||
|
||||
db := repository.GetDB()
|
||||
db.Lock()
|
||||
db.License = &models.License{
|
||||
Plan: "pro", Source: "opsx", LicenseKey: "LIC-KEEP-ON-FAIL",
|
||||
InstanceID: GetOrCreateInstanceID(), ActivationToken: "tok",
|
||||
ActivatedAt: time.Now(), IssuedAt: time.Now(), LastVerifiedAt: time.Now().Unix(),
|
||||
}
|
||||
db.RequestSave()
|
||||
db.Unlock()
|
||||
|
||||
_, err := DeactivateLicense()
|
||||
if err == nil {
|
||||
t.Fatal("expected remote failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "远端解绑失败") {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
stored := StoredLicense()
|
||||
if stored == nil || stored.LicenseKey != "LIC-KEEP-ON-FAIL" || stored.Plan != "pro" {
|
||||
t.Fatalf("must keep local state on failure: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateLicenseOnlineSuccessClears(t *testing.T) {
|
||||
withTempDB(t)
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/license/deactivate" {
|
||||
t.Errorf("path %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "message": "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := config.GetConfig()
|
||||
prev := cfg.LicenseAPIBase
|
||||
cfg.LicenseAPIBase = srv.URL
|
||||
defer func() { cfg.LicenseAPIBase = prev }()
|
||||
|
||||
id := GetOrCreateInstanceID()
|
||||
db := repository.GetDB()
|
||||
db.Lock()
|
||||
db.License = &models.License{
|
||||
Plan: "pro", Source: "opsx", LicenseKey: "LIC-OK",
|
||||
InstanceID: id, ActivationToken: "act-token",
|
||||
ActivatedAt: time.Now(), IssuedAt: time.Now(), LastVerifiedAt: time.Now().Unix(),
|
||||
}
|
||||
db.RequestSave()
|
||||
db.Unlock()
|
||||
|
||||
res, err := DeactivateLicense()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.OfflineLocalOnly {
|
||||
t.Fatal("online should not set offlineLocalOnly")
|
||||
}
|
||||
if gotBody["license_key"] != "LIC-OK" || gotBody["device_id"] != id || gotBody["activation_token"] != "act-token" {
|
||||
t.Fatalf("bad deactivate body: %#v", gotBody)
|
||||
}
|
||||
if StoredLicense() != nil {
|
||||
t.Fatal("expected cleared after success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateLicenseEmpty(t *testing.T) {
|
||||
withTempDB(t)
|
||||
if _, err := DeactivateLicense(); err == nil {
|
||||
t.Fatal("expected error when no license")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user