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 })
}
+17 -13
View File
@@ -13,17 +13,29 @@ import (
type KeygenClient struct {
AccountID string
Token string
BaseURL string
HTTPClient *http.Client
}
const defaultAPIBaseURL = "https://api.keygen.sh/v1"
func NewKeygenClient(accountID, token string) *KeygenClient {
return &KeygenClient{
AccountID: accountID,
Token: token,
AccountID: accountID,
Token: token,
BaseURL: defaultAPIBaseURL,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (c *KeygenClient) apiURL(path string) string {
baseURL := strings.TrimRight(c.BaseURL, "/")
if baseURL == "" {
baseURL = defaultAPIBaseURL
}
return fmt.Sprintf("%s/accounts/%s/%s", baseURL, c.AccountID, strings.TrimLeft(path, "/"))
}
type ValidateResponse struct {
Meta struct {
Valid bool `json:"valid"`
@@ -55,7 +67,7 @@ type ActivateMachineRequest struct {
}
func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
url := c.apiURL("licenses/actions/validate-key")
meta := map[string]interface{}{
"key": key,
@@ -103,7 +115,7 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
}
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/licenses/actions/validate-key", c.AccountID)
url := c.apiURL("licenses/actions/validate-key")
reqBody := map[string]interface{}{
"meta": map[string]string{
@@ -142,7 +154,7 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
}
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
url := fmt.Sprintf("https://api.keygen.sh/v1/accounts/%s/machines", c.AccountID)
url := c.apiURL("machines")
var reqBody ActivateMachineRequest
reqBody.Data.Type = "machines"
@@ -175,13 +187,5 @@ func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusConflict || resp.StatusCode == http.StatusUnprocessableEntity {
if strings.Contains(string(body), "FINGERPRINT_TAKEN") || strings.Contains(string(body), "MACHINE_LIMIT_EXCEEDED") {
// Machine already registered to this license or limit reached because it's already us.
// The subsequent ValidateKey check will determine if the existing machine is actually us.
return nil
}
}
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
}
+52 -9
View File
@@ -49,6 +49,30 @@ const readCachedConfigs = (keys: readonly string[]) => {
return { cachedConfigs, hasCachedData };
};
const readAllCachedSafeConfigs = () => {
const cachedConfigs: Record<string, string> = {};
Object.keys(localStorage).forEach((storageKey) => {
if (!storageKey.startsWith(CACHE_PREFIX)) {
return;
}
const key = storageKey.slice(CACHE_PREFIX.length).trim().toLowerCase();
if (!key || SENSITIVE_CONFIG_KEYS.has(key)) {
return;
}
const value = localStorage.getItem(storageKey);
if (value !== null) {
cachedConfigs[key] = value;
}
});
return cachedConfigs;
};
const fetchPublicBrandConfigs = async (): Promise<Record<string, string>> => {
const publicConfigMap: Record<string, string> = {};
@@ -206,20 +230,24 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
// 获取所有配置(优先从缓存)
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
const { cachedConfigs, hasCachedData } = readCachedConfigs(
PUBLIC_BRAND_CONFIG_KEYS,
);
const {
cachedConfigs: publicCachedConfigs,
hasCachedData: hasPublicCachedData,
} = readCachedConfigs(PUBLIC_BRAND_CONFIG_KEYS);
if (!isLoggedIn()) {
const publicConfigs = await fetchPublicBrandConfigs();
if (Object.keys(publicConfigs).length > 0) {
return { ...cachedConfigs, ...publicConfigs };
return { ...publicCachedConfigs, ...publicConfigs };
}
return cachedConfigs;
return publicCachedConfigs;
}
const cachedConfigs = readAllCachedSafeConfigs();
const hasCachedData = Object.keys(cachedConfigs).length > 0;
// 从API获取最新配置
try {
const response = await getConfigs();
@@ -249,14 +277,20 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
return cachedConfigs;
}
return await fetchPublicBrandConfigs();
const publicConfigs = await fetchPublicBrandConfigs();
return { ...publicCachedConfigs, ...publicConfigs };
} catch {
// API失败时返回缓存的数据
if (hasCachedData) {
return cachedConfigs;
}
return await fetchPublicBrandConfigs();
const publicConfigs = await fetchPublicBrandConfigs();
return hasPublicCachedData
? { ...publicCachedConfigs, ...publicConfigs }
: publicConfigs;
}
};
@@ -365,8 +399,17 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
siteConfig.app_logo = appLogo;
siteConfig.app_favicon = appFavicon;
siteConfig.app_bg_image = appBgImage;
siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true";
siteConfig.hide_footer_brand = resolvedConfigMap.hide_footer_brand === "true";
if (
Object.prototype.hasOwnProperty.call(resolvedConfigMap, "is_commercial")
) {
siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true";
}
if (
Object.prototype.hasOwnProperty.call(resolvedConfigMap, "hide_footer_brand")
) {
siteConfig.hide_footer_brand =
resolvedConfigMap.hide_footer_brand === "true";
}
if (typeof document !== "undefined") {
document.title = siteConfig.name;
+3
View File
@@ -266,6 +266,9 @@ const getInitialConfigs = (): Record<string, string> => {
"github_proxy_enabled",
"github_proxy_url",
"allow_local_remote_addr",
"is_commercial",
"license_expiry",
"hide_footer_brand",
];
const initialConfigs: Record<string, string> = {};