fix: preserve license state across panel upgrades (#544)

This commit is contained in:
sagit
2026-08-11 17:04:20 +08:00
committed by GitHub
parent a820c49c94
commit 0e24b53a5b
7 changed files with 288 additions and 59 deletions
+4 -28
View File
@@ -20,7 +20,6 @@ import (
"go-backend/internal/health"
"go-backend/internal/http/middleware"
"go-backend/internal/http/response"
"go-backend/internal/license"
"go-backend/internal/metrics"
"go-backend/internal/monitoring"
runtimenft "go-backend/internal/runtime/nftables"
@@ -909,38 +908,15 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
return
}
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
fingerprint, err := h.getOrCreateMachineFingerprint()
valResp, err := h.validateLicenseForMachine(key)
if err != nil {
response.WriteJSON(w, response.ErrDefault("生成设备指纹失败"))
return
}
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
response.WriteJSON(w, response.ErrDefault("连接授权服务器失败: "+err.Error()))
response.WriteJSON(w, response.ErrDefault("授权校验失败: "+err.Error()))
return
}
if !valResp.Meta.Valid {
if valResp.Meta.Code == "NO_MACHINES" || valResp.Meta.Code == "NO_MACHINE" || valResp.Meta.Code == "MACHINE_SCOPE_REQUIRED" || valResp.Meta.Code == "FINGERPRINT_SCOPE_MISMATCH" {
// Needs machine activation
client.Token = key
err = client.ActivateMachine(valResp.Data.ID, fingerprint)
if err != nil {
// Translate specific error messages or log them
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
return
}
// Validation might still fail with scope if we don't query via machine id, but since activate machine succeeded
// we can consider the license valid for our simple usecase
} else {
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
}
response.WriteJSON(w, response.ErrDefault("授权码无效或已过期 (Code: "+valResp.Meta.Code+")"))
return
}
now := time.Now().UnixMilli()
+8 -8
View File
@@ -4,8 +4,6 @@ import (
"context"
"log"
"time"
"go-backend/internal/license"
)
var nftablesTrafficCollectInterval = 30 * time.Second
@@ -56,8 +54,6 @@ func (h *Handler) validateLicenseJob() {
return
}
accountID := "1bc96cac-09de-4cf4-af34-26afdad63a90"
key, _ := h.repo.GetViteConfigValue("license_key")
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
@@ -65,12 +61,16 @@ func (h *Handler) validateLicenseJob() {
return // Nothing to validate
}
fingerprint, _ := h.repo.GetViteConfigValue("machine_fingerprint")
client := license.NewKeygenClient(accountID, "")
valResp, err := client.ValidateKeyWithFingerprint(key, fingerprint)
valResp, err := h.validateLicenseForMachine(key)
if err != nil {
// Network error or timeout. Grace period by not revoking immediately here.
// Network and decode failures have no validation response, so retain the
// current state as a grace period. A rejected machine binding still has
// the original invalid response and must not stay commercially enabled.
if valResp != nil && !valResp.Meta.Valid {
now := time.Now().UnixMilli()
_ = h.repo.UpsertConfig("is_commercial", "false", now)
}
return
}
@@ -0,0 +1,48 @@
package handler
import (
"fmt"
"strings"
"go-backend/internal/license"
)
const keygenAccountID = "1bc96cac-09de-4cf4-af34-26afdad63a90"
var newLicenseClient = license.NewKeygenClient
func licenseNeedsMachineActivation(code string) bool {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "NO_MACHINES", "NO_MACHINE", "MACHINE_SCOPE_REQUIRED", "FINGERPRINT_SCOPE_MISMATCH":
return true
default:
return false
}
}
func (h *Handler) validateLicenseForMachine(key string) (*license.ValidateResponse, error) {
fingerprint, err := h.getOrCreateMachineFingerprint()
if err != nil {
return nil, fmt.Errorf("prepare machine fingerprint: %w", err)
}
client := newLicenseClient(keygenAccountID, "")
validation, err := client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
return nil, err
}
if validation.Meta.Valid || !licenseNeedsMachineActivation(validation.Meta.Code) {
return validation, nil
}
client.Token = key
if err := client.ActivateMachine(validation.Data.ID, fingerprint); err != nil {
return validation, err
}
validation, err = client.ValidateKeyWithFingerprint(key, fingerprint)
if err != nil {
return nil, err
}
return validation, nil
}
@@ -0,0 +1,155 @@
package handler
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"go-backend/internal/license"
"go-backend/internal/store/repo"
)
func TestValidateLicenseJobRepairsMissingMachineBinding(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
var validations atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
if validations.Add(1) == 1 {
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
return
}
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"2030-01-02T00:00:00.000Z"}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprint(w, `{}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "true")
assertLicenseConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
fingerprint, err := r.GetViteConfigValue("machine_fingerprint")
if err != nil || strings.TrimSpace(fingerprint) == "" {
t.Fatalf("expected persisted machine fingerprint, got value=%q err=%v", fingerprint, err)
}
if got := validations.Load(); got != 2 {
t.Fatalf("validation calls = %d, want 2", got)
}
}
func TestLicenseActivateRequiresSuccessfulPostActivationValidation(t *testing.T) {
r := openLicenseTestRepository(t)
var validations atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
code := "NO_MACHINE"
if validations.Add(1) > 1 {
code = "FINGERPRINT_SCOPE_MISMATCH"
}
_, _ = fmt.Fprintf(w, `{"meta":{"valid":false,"code":%q},"data":{"id":"license-id","attributes":{}}}`, code)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprint(w, `{}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", bytes.NewBufferString(`{"license_key":"license-secret"}`))
res := httptest.NewRecorder()
h.licenseActivate(res, req)
if !strings.Contains(res.Body.String(), "FINGERPRINT_SCOPE_MISMATCH") {
t.Fatalf("expected post-activation validation failure, got %s", res.Body.String())
}
if value, err := r.GetViteConfigValue("is_commercial"); err == nil || value != "" {
t.Fatalf("commercial status should not be persisted, got value=%q err=%v", value, err)
}
}
func TestValidateLicenseJobDowngradesWhenMachineBindingIsRejected(t *testing.T) {
r := openLicenseTestRepository(t)
now := time.Now().UnixMilli()
seedLicenseConfig(t, r, "license_key", "license-secret", now)
seedLicenseConfig(t, r, "is_commercial", "true", now)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key"):
_, _ = fmt.Fprint(w, `{"meta":{"valid":false,"code":"NO_MACHINE"},"data":{"id":"license-id","attributes":{}}}`)
case strings.HasSuffix(req.URL.Path, "/machines"):
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = fmt.Fprint(w, `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
default:
http.NotFound(w, req)
}
}))
defer server.Close()
restoreLicenseClientFactory(t, server.URL)
h := &Handler{repo: r}
h.validateLicenseJob()
assertLicenseConfig(t, r, "is_commercial", "false")
}
func openLicenseTestRepository(t *testing.T) *repo.Repository {
t.Helper()
r, err := repo.Open(filepath.Join(t.TempDir(), "license.db"))
if err != nil {
t.Fatalf("repo.Open() error = %v", err)
}
t.Cleanup(func() { _ = r.Close() })
return r
}
func seedLicenseConfig(t *testing.T, r *repo.Repository, name, value string, now int64) {
t.Helper()
if err := r.UpsertConfig(name, value, now); err != nil {
t.Fatalf("UpsertConfig(%q) error = %v", name, err)
}
}
func assertLicenseConfig(t *testing.T, r *repo.Repository, name, want string) {
t.Helper()
got, err := r.GetViteConfigValue(name)
if err != nil {
t.Fatalf("GetViteConfigValue(%q) error = %v", name, err)
}
if got != want {
t.Fatalf("config %q = %q, want %q", name, got, want)
}
}
func restoreLicenseClientFactory(t *testing.T, baseURL string) {
t.Helper()
previous := newLicenseClient
newLicenseClient = func(accountID, token string) *license.KeygenClient {
client := license.NewKeygenClient(accountID, token)
client.BaseURL = baseURL
return client
}
t.Cleanup(func() { newLicenseClient = previous })
}