mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-14 16:54:24 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f26014579b | |||
| 5041c722c9 | |||
| c56798e991 |
@@ -229,6 +229,7 @@ jobs:
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg KEYGEN_ACCOUNT_ID=${{ secrets.KEYGEN_ACCOUNT_ID }} \
|
||||
--push \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:latest \
|
||||
-t ${{ env.REGISTRY }}/${OWNER}/flux-panel-backend:${VERSION} \
|
||||
@@ -431,4 +432,3 @@ jobs:
|
||||
gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber
|
||||
|
||||
echo "✅ GOST 二进制文件更新完成"
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ RUN go mod download
|
||||
COPY . .
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
|
||||
ARG KEYGEN_ACCOUNT_ID
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -ldflags="-X 'go-backend/internal/license.AccountID=${KEYGEN_ACCOUNT_ID}'" -o /out/paneld ./cmd/paneld
|
||||
|
||||
FROM docker:27-cli AS dockercli
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request)
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
if value, gated := h.unlicensedPublicBrandValue(configName); gated {
|
||||
response.WriteJSON(w, response.OK(map[string]string{"name": configName, "value": value}))
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.repo.GetConfigByName(configName)
|
||||
if err != nil {
|
||||
@@ -42,3 +46,22 @@ func (h *Handler) getPublicConfigByName(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
response.WriteJSON(w, response.OK(cfg))
|
||||
}
|
||||
|
||||
func (h *Handler) unlicensedPublicBrandValue(configName string) (string, bool) {
|
||||
switch configName {
|
||||
case "app_name", "app_logo", "app_favicon", "hide_footer_brand":
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
if isCommercial == "true" {
|
||||
return "", false
|
||||
}
|
||||
if configName == "app_name" {
|
||||
return "FLVX", true
|
||||
}
|
||||
if configName == "hide_footer_brand" {
|
||||
return "false", true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
@@ -30,6 +30,40 @@ func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
|
||||
assertHandlerCode(t, resp, 0)
|
||||
}
|
||||
|
||||
func TestPublicBrandConfigFallsBackWithoutCommercialLicense(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
seedConfigValue(t, r, "app_name", "Paid Brand")
|
||||
seedConfigValue(t, r, "app_logo", "logo-data")
|
||||
seedConfigValue(t, r, "app_favicon", "favicon-data")
|
||||
seedConfigValue(t, r, "hide_footer_brand", "true")
|
||||
seedConfigValue(t, r, "is_commercial", "false")
|
||||
|
||||
for name, want := range map[string]string{
|
||||
"app_name": "FLVX",
|
||||
"app_logo": "",
|
||||
"app_favicon": "",
|
||||
"hide_footer_brand": "false",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
assertHandlerConfigValue(t, resp, name, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicBrandConfigUsesSavedValuesWithCommercialLicense(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
seedConfigValue(t, r, "app_name", "Paid Brand")
|
||||
seedConfigValue(t, r, "is_commercial", "true")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
assertHandlerConfigValue(t, resp, "app_name", "Paid Brand")
|
||||
}
|
||||
|
||||
func TestPublicConfigGetRejectsSensitiveKeys(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
|
||||
@@ -82,6 +116,56 @@ func TestConfigGetAllowsSensitiveKeysForAdmin(t *testing.T) {
|
||||
assertHandlerConfigValue(t, resp, "jwt_secret", "jwt-secret")
|
||||
}
|
||||
|
||||
func TestConfigGetNeverReturnsLicenseCredentials(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
seedConfigValue(t, r, "license_key", "license-secret")
|
||||
seedConfigValue(t, r, "machine_fingerprint", "fingerprint-secret")
|
||||
|
||||
for _, name := range []string{"license_key", "license_machine_id", "machine_fingerprint"} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
assertHandlerCodeMsg(t, resp, 403, "禁止访问系统授权凭据")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigListNeverReturnsLicenseCredentials(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
seedConfigValue(t, r, "license_key", "license-secret")
|
||||
seedConfigValue(t, r, "license_machine_id", "machine-id")
|
||||
seedConfigValue(t, r, "machine_fingerprint", "fingerprint-secret")
|
||||
seedConfigValue(t, r, "is_commercial", "true")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/config/list", nil)
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
var out struct {
|
||||
Code int `json:"code"`
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code != 0 || out.Data["is_commercial"] != "true" {
|
||||
t.Fatalf("unexpected config response: %+v", out)
|
||||
}
|
||||
if _, ok := out.Data["license_key"]; ok {
|
||||
t.Fatal("license_key must not be returned")
|
||||
}
|
||||
if _, ok := out.Data["license_machine_id"]; ok {
|
||||
t.Fatal("license_machine_id must not be returned")
|
||||
}
|
||||
if _, ok := out.Data["machine_fingerprint"]; ok {
|
||||
t.Fatal("machine_fingerprint must not be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdateAllowsSensitiveKeysForAdmin(t *testing.T) {
|
||||
router, _ := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
@@ -154,7 +238,7 @@ func TestConfigUpdateSingleAllowsCloudflareSecretKeyWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdateAllowsLicenseKeyWrite(t *testing.T) {
|
||||
func TestConfigUpdateRejectsLicenseKeyWrite(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
@@ -165,18 +249,13 @@ func TestConfigUpdateAllowsLicenseKeyWrite(t *testing.T) {
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
|
||||
cfg, err := r.GetConfigByName("license_key")
|
||||
if err != nil {
|
||||
t.Fatalf("get config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "license-secret" {
|
||||
t.Fatalf("expected license_key to be updated, got %#v", cfg)
|
||||
assertHandlerCodeMsg(t, resp, -1, "该配置由系统管理")
|
||||
if cfg, err := r.GetConfigByName("license_key"); err != nil || cfg != nil {
|
||||
t.Fatalf("license_key should not be written, got %#v err=%v", cfg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigUpdateSingleAllowsLicenseKeyWrite(t *testing.T) {
|
||||
func TestConfigUpdateSingleRejectsLicenseKeyWrite(t *testing.T) {
|
||||
router, r := setupConfigAccessTestRouter(t)
|
||||
adminToken := mustGenerateConfigAccessToken(t, 1, "admin_user", 0)
|
||||
|
||||
@@ -187,14 +266,9 @@ func TestConfigUpdateSingleAllowsLicenseKeyWrite(t *testing.T) {
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertHandlerCode(t, resp, 0)
|
||||
|
||||
cfg, err := r.GetConfigByName("license_key")
|
||||
if err != nil {
|
||||
t.Fatalf("get config: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.Value != "license-secret" {
|
||||
t.Fatalf("expected license_key to be updated, got %#v", cfg)
|
||||
assertHandlerCodeMsg(t, resp, -1, "该配置由系统管理")
|
||||
if cfg, err := r.GetConfigByName("license_key"); err != nil || cfg != nil {
|
||||
t.Fatalf("license_key should not be written, got %#v err=%v", cfg, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -41,10 +42,12 @@ type Handler struct {
|
||||
captchaMu sync.Mutex
|
||||
captchaTokens map[string]int64
|
||||
|
||||
jobsMu sync.Mutex
|
||||
jobsCancel context.CancelFunc
|
||||
jobsStarted bool
|
||||
jobsWG sync.WaitGroup
|
||||
jobsMu sync.Mutex
|
||||
jobsCancel context.CancelFunc
|
||||
jobsStarted bool
|
||||
jobsWG sync.WaitGroup
|
||||
fingerprintMu sync.Mutex
|
||||
licenseValidationMu sync.Mutex
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
systemUpgradeMu sync.Mutex
|
||||
@@ -399,6 +402,10 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
configName := strings.ToLower(strings.TrimSpace(req.Name))
|
||||
if configName == "license_key" || configName == "license_machine_id" || configName == "machine_fingerprint" {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问系统授权凭据"))
|
||||
return
|
||||
}
|
||||
if repo.IsSensitiveConfigKey(configName) && !isAdminRequest(r) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
@@ -434,11 +441,13 @@ func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
ctxClaims := r.Context().Value(middleware.ClaimsContextKey)
|
||||
if claims, ok := ctxClaims.(auth.Claims); !ok || claims.RoleID != 0 {
|
||||
delete(cfgMap, "license_key")
|
||||
delete(cfgMap, "cloudflare_secret_key")
|
||||
delete(cfgMap, "jwt_secret")
|
||||
claims, isAdmin := ctxClaims.(auth.Claims)
|
||||
if !isAdmin || claims.RoleID != 0 {
|
||||
cfgMap = repo.FilterSensitiveConfigs(cfgMap)
|
||||
}
|
||||
delete(cfgMap, "license_key")
|
||||
delete(cfgMap, "license_machine_id")
|
||||
delete(cfgMap, "machine_fingerprint")
|
||||
response.WriteJSON(w, response.OK(cfgMap))
|
||||
}
|
||||
|
||||
@@ -877,10 +886,16 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) getOrCreateMachineFingerprint() (string, error) {
|
||||
fp, _ := h.repo.GetViteConfigValue("machine_fingerprint")
|
||||
h.fingerprintMu.Lock()
|
||||
defer h.fingerprintMu.Unlock()
|
||||
|
||||
fp, err := h.repo.GetViteConfigValue("machine_fingerprint")
|
||||
if fp != "" {
|
||||
return fp, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
newFp := uuid.New().String()
|
||||
now := time.Now().UnixMilli()
|
||||
@@ -907,10 +922,13 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("授权码不能为空"))
|
||||
return
|
||||
}
|
||||
h.licenseValidationMu.Lock()
|
||||
defer h.licenseValidationMu.Unlock()
|
||||
|
||||
valResp, err := h.validateLicenseForMachine(key)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("授权校验失败: "+err.Error()))
|
||||
log.Printf("license activation failed: %v", err)
|
||||
response.WriteJSON(w, response.ErrDefault(licenseValidationErrorMessage(err)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -920,20 +938,19 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := h.repo.UpsertConfig("license_key", key, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.UpsertConfig("is_commercial", "true", now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
expiry := valResp.Data.Attributes.Expiry
|
||||
if expiry == "" {
|
||||
expiry = "never"
|
||||
}
|
||||
if err := h.repo.UpsertConfig("license_expiry", expiry, now); err != nil {
|
||||
licenseState := map[string]string{
|
||||
"license_key": key,
|
||||
"is_commercial": "true",
|
||||
"license_expiry": expiry,
|
||||
}
|
||||
if valResp.MachineID != "" {
|
||||
licenseState["license_machine_id"] = valResp.MachineID
|
||||
}
|
||||
if err := h.repo.UpsertConfigs(licenseState, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -975,6 +992,10 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
if repo.IsSystemManagedConfigKey(key) {
|
||||
response.WriteJSON(w, response.ErrDefault("该配置由系统管理"))
|
||||
return
|
||||
}
|
||||
|
||||
if protectedKeys[key] && isCommercial != "true" {
|
||||
response.WriteJSON(w, response.ErrDefault("需要商业版授权"))
|
||||
@@ -1017,6 +1038,10 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
|
||||
return
|
||||
}
|
||||
if repo.IsSystemManagedConfigKey(name) {
|
||||
response.WriteJSON(w, response.ErrDefault("该配置由系统管理"))
|
||||
return
|
||||
}
|
||||
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
if (name == "app_name" || name == "app_logo" || name == "app_favicon" || name == "hide_footer_brand") && isCommercial != "true" {
|
||||
|
||||
@@ -36,6 +36,7 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
|
||||
func (h *Handler) runValidateLicenseJob(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
h.validateLicenseJob()
|
||||
ticker := time.NewTicker(12 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -53,11 +54,12 @@ func (h *Handler) validateLicenseJob() {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
h.licenseValidationMu.Lock()
|
||||
defer h.licenseValidationMu.Unlock()
|
||||
|
||||
key, _ := h.repo.GetViteConfigValue("license_key")
|
||||
isCommercial, _ := h.repo.GetViteConfigValue("is_commercial")
|
||||
|
||||
if key == "" || isCommercial != "true" {
|
||||
if key == "" {
|
||||
return // Nothing to validate
|
||||
}
|
||||
|
||||
@@ -67,7 +69,7 @@ func (h *Handler) validateLicenseJob() {
|
||||
// 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 {
|
||||
if licenseValidationErrorIsDefinitive(valResp, err) {
|
||||
now := time.Now().UnixMilli()
|
||||
_ = h.repo.UpsertConfig("is_commercial", "false", now)
|
||||
}
|
||||
@@ -84,7 +86,14 @@ func (h *Handler) validateLicenseJob() {
|
||||
if expiry == "" {
|
||||
expiry = "never"
|
||||
}
|
||||
_ = h.repo.UpsertConfig("license_expiry", expiry, now)
|
||||
licenseState := map[string]string{
|
||||
"is_commercial": "true",
|
||||
"license_expiry": expiry,
|
||||
}
|
||||
if valResp.MachineID != "" {
|
||||
licenseState["license_machine_id"] = valResp.MachineID
|
||||
}
|
||||
_ = h.repo.UpsertConfigs(licenseState, now)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go-backend/internal/license"
|
||||
)
|
||||
|
||||
const keygenAccountID = "1bc96cac-09de-4cf4-af34-26afdad63a90"
|
||||
|
||||
var newLicenseClient = license.NewKeygenClient
|
||||
|
||||
func keygenAccountID() string {
|
||||
if value := strings.TrimSpace(license.AccountID); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("KEYGEN_ACCOUNT_ID"))
|
||||
}
|
||||
|
||||
func licenseNeedsMachineActivation(code string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(code)) {
|
||||
case "NO_MACHINES", "NO_MACHINE", "MACHINE_SCOPE_REQUIRED", "FINGERPRINT_SCOPE_MISMATCH":
|
||||
@@ -20,29 +28,82 @@ func licenseNeedsMachineActivation(code string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func licenseValidationErrorIsDefinitive(validation *license.ValidateResponse, err error) bool {
|
||||
if err == nil {
|
||||
return validation != nil && !validation.Meta.Valid
|
||||
}
|
||||
var apiErr *license.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return false
|
||||
}
|
||||
if apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError {
|
||||
return false
|
||||
}
|
||||
return apiErr.Operation == "activate machine" && validation != nil && !validation.Meta.Valid
|
||||
}
|
||||
|
||||
func licenseValidationErrorMessage(err error) string {
|
||||
if strings.Contains(err.Error(), "keygen account id is not configured") {
|
||||
return "授权服务配置错误"
|
||||
}
|
||||
var apiErr *license.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr.HasCode("MACHINE_LIMIT_EXCEEDED") {
|
||||
return "授权设备数量已达上限"
|
||||
}
|
||||
if apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden {
|
||||
return "授权码无效或无权绑定设备"
|
||||
}
|
||||
}
|
||||
return "连接授权服务器失败,请稍后重试"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
storedMachineID, _ := h.repo.GetViteConfigValue("license_machine_id")
|
||||
|
||||
client := newLicenseClient(keygenAccountID, "")
|
||||
validation, err := client.ValidateKeyWithFingerprint(key, fingerprint)
|
||||
accountID := keygenAccountID()
|
||||
if accountID == "" {
|
||||
return nil, fmt.Errorf("keygen account id is not configured")
|
||||
}
|
||||
client := newLicenseClient(accountID, "")
|
||||
var validation *license.ValidateResponse
|
||||
if storedMachineID != "" {
|
||||
validation, err = client.ValidateKeyWithMachine(key, fingerprint, storedMachineID)
|
||||
} else {
|
||||
validation, err = client.ValidateKeyWithFingerprint(key, fingerprint)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validation.Meta.Valid || !licenseNeedsMachineActivation(validation.Meta.Code) {
|
||||
if validation.Meta.Valid {
|
||||
validation.MachineID = storedMachineID
|
||||
}
|
||||
return validation, nil
|
||||
}
|
||||
|
||||
client.Token = key
|
||||
if err := client.ActivateMachine(validation.Data.ID, fingerprint); err != nil {
|
||||
machineID, err := client.ActivateMachine(validation.Data.ID, fingerprint)
|
||||
if err != nil {
|
||||
return validation, err
|
||||
}
|
||||
if machineID == "" {
|
||||
machineID, err = client.GetMachineID(fingerprint)
|
||||
if err != nil {
|
||||
return validation, fmt.Errorf("retrieve activated machine: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
validation, err = client.ValidateKeyWithFingerprint(key, fingerprint)
|
||||
validation, err = client.ValidateKeyWithMachine(key, fingerprint, machineID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validation.Meta.Valid {
|
||||
validation.MachineID = machineID
|
||||
}
|
||||
return validation, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -32,7 +33,9 @@ func TestValidateLicenseJobRepairsMissingMachineBinding(t *testing.T) {
|
||||
_, _ = 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, `{}`)
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
case strings.Contains(req.URL.Path, "/machines/"):
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
@@ -54,6 +57,43 @@ func TestValidateLicenseJobRepairsMissingMachineBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobAcceptsExistingMachineActivation(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":"never"}}}`)
|
||||
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"},{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||
case strings.Contains(req.URL.Path, "/machines/"):
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
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", "never")
|
||||
if got := validations.Load(); got != 2 {
|
||||
t.Fatalf("validation calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseActivateRequiresSuccessfulPostActivationValidation(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
|
||||
@@ -68,7 +108,9 @@ func TestLicenseActivateRequiresSuccessfulPostActivationValidation(t *testing.T)
|
||||
_, _ = 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, `{}`)
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
case strings.Contains(req.URL.Path, "/machines/"):
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
@@ -84,11 +126,41 @@ func TestLicenseActivateRequiresSuccessfulPostActivationValidation(t *testing.T)
|
||||
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)
|
||||
assertLicenseConfig(t, r, "is_commercial", "false")
|
||||
for _, name := range []string{"license_key", "license_expiry"} {
|
||||
if value, err := r.GetViteConfigValue(name); err == nil || value != "" {
|
||||
t.Fatalf("%s should not be persisted, got value=%q err=%v", name, value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseActivatePersistsValidatedState(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
|
||||
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":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"2030-01-02T00:00:00.000Z"}}}`)
|
||||
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(), `"code":0`) {
|
||||
t.Fatalf("expected activation success, got %s", res.Body.String())
|
||||
}
|
||||
assertLicenseConfig(t, r, "license_key", "license-secret")
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
assertLicenseConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobDowngradesWhenMachineBindingIsRejected(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
now := time.Now().UnixMilli()
|
||||
@@ -115,6 +187,136 @@ func TestValidateLicenseJobDowngradesWhenMachineBindingIsRejected(t *testing.T)
|
||||
assertLicenseConfig(t, r, "is_commercial", "false")
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobRestoresCommercialStateWhenLicenseRecovers(t *testing.T) {
|
||||
r := openLicenseTestRepository(t)
|
||||
now := time.Now().UnixMilli()
|
||||
seedLicenseConfig(t, r, "license_key", "license-secret", now)
|
||||
seedLicenseConfig(t, r, "is_commercial", "false", now)
|
||||
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key") {
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
|
||||
return
|
||||
}
|
||||
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", "never")
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobUsesStoredMachineScope(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)
|
||||
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", now)
|
||||
seedLicenseConfig(t, r, "license_machine_id", "machine-id", now)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if !strings.HasSuffix(req.URL.Path, "/licenses/actions/validate-key") {
|
||||
t.Fatalf("unexpected request %s %s", req.Method, req.URL.Path)
|
||||
}
|
||||
var body struct {
|
||||
Meta struct {
|
||||
Scope map[string]string `json:"scope"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body.Meta.Scope["machine"] != "machine-id" || body.Meta.Scope["fingerprint"] != "fingerprint" {
|
||||
t.Fatalf("unexpected validation scope: %+v", body.Meta.Scope)
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{"expiry":"never"}}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.validateLicenseJob()
|
||||
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
assertLicenseConfig(t, r, "license_machine_id", "machine-id")
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobKeepsStateOnMachineLookupServerFailure(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)
|
||||
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", 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":"MACHINE_SCOPE_REQUIRED"},"data":{"id":"license-id","attributes":{}}}`)
|
||||
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"}]}`)
|
||||
case strings.Contains(req.URL.Path, "/machines/"):
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"SERVICE_UNAVAILABLE"}]}`)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.validateLicenseJob()
|
||||
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
}
|
||||
|
||||
func TestValidateLicenseJobKeepsStateOnMachineLookupNotFound(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)
|
||||
seedLicenseConfig(t, r, "machine_fingerprint", "fingerprint", 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":"MACHINE_SCOPE_REQUIRED"},"data":{"id":"license-id","attributes":{}}}`)
|
||||
case strings.HasSuffix(req.URL.Path, "/machines"):
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"}]}`)
|
||||
case strings.Contains(req.URL.Path, "/machines/"):
|
||||
http.NotFound(w, req)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
restoreLicenseClientFactory(t, server.URL)
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.validateLicenseJob()
|
||||
|
||||
assertLicenseConfig(t, r, "is_commercial", "true")
|
||||
}
|
||||
|
||||
func TestLicenseValidationErrorMessageDoesNotExposeKeygenResponse(t *testing.T) {
|
||||
err := &license.APIError{
|
||||
Operation: "activate machine",
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Body: `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED","detail":"private detail"}]}`,
|
||||
}
|
||||
message := licenseValidationErrorMessage(err)
|
||||
if message != "授权设备数量已达上限" || strings.Contains(message, "private detail") {
|
||||
t.Fatalf("unexpected public error message %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
func openLicenseTestRepository(t *testing.T) *repo.Repository {
|
||||
t.Helper()
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "license.db"))
|
||||
@@ -145,6 +347,7 @@ func assertLicenseConfig(t *testing.T, r *repo.Repository, name, want string) {
|
||||
|
||||
func restoreLicenseClientFactory(t *testing.T, baseURL string) {
|
||||
t.Helper()
|
||||
t.Setenv("KEYGEN_ACCOUNT_ID", "account-id")
|
||||
previous := newLicenseClient
|
||||
newLicenseClient = func(accountID, token string) *license.KeygenClient {
|
||||
client := license.NewKeygenClient(accountID, token)
|
||||
|
||||
@@ -182,6 +182,8 @@ func requiresAdmin(path string) bool {
|
||||
return true
|
||||
case "/api/v1/config/update", "/api/v1/config/update-single":
|
||||
return true
|
||||
case "/api/v1/license/activate":
|
||||
return true
|
||||
case "/api/v1/announcement/update":
|
||||
return true
|
||||
default:
|
||||
|
||||
@@ -197,6 +197,39 @@ func TestShouldSkipBypassesPublicConfigGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseActivateRequiresAdmin(t *testing.T) {
|
||||
if !requiresAdmin("/api/v1/license/activate") {
|
||||
t.Fatal("expected license activation to require admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTRejectsNonAdminLicenseActivation(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(2, "regular_user", 1, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate token: %v", err)
|
||||
}
|
||||
claims, err := auth.ParseClaims(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse claims: %v", err)
|
||||
}
|
||||
|
||||
wrapped := JWT(AuthOptions{
|
||||
JWTSecret: secret,
|
||||
GetUserAuthState: func(userID int64) (*auth.UserAuthState, error) {
|
||||
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: claims.IatMs - 1}, nil
|
||||
},
|
||||
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OK("pass"))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/license/activate", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
res := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(res, req)
|
||||
assertCodeMsg(t, res, 403, "权限不足,仅管理员可操作")
|
||||
}
|
||||
|
||||
func TestJWTExpiresAfterSevenDays(t *testing.T) {
|
||||
secret := "unit-test-secret"
|
||||
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var AccountID string
|
||||
|
||||
type KeygenClient struct {
|
||||
AccountID string
|
||||
Token string
|
||||
@@ -17,6 +20,20 @@ type KeygenClient struct {
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Operation string
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("keygen %s failed: status %d, response: %s", e.Operation, e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
func (e *APIError) HasCode(code string) bool {
|
||||
return e != nil && hasKeygenErrorCode([]byte(e.Body), code)
|
||||
}
|
||||
|
||||
const defaultAPIBaseURL = "https://api.keygen.sh/v1"
|
||||
|
||||
func NewKeygenClient(accountID, token string) *KeygenClient {
|
||||
@@ -47,6 +64,7 @@ type ValidateResponse struct {
|
||||
Expiry string `json:"expiry"`
|
||||
} `json:"attributes"`
|
||||
} `json:"data"`
|
||||
MachineID string `json:"-"`
|
||||
}
|
||||
|
||||
type ActivateMachineRequest struct {
|
||||
@@ -66,7 +84,36 @@ type ActivateMachineRequest struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type keygenErrorResponse struct {
|
||||
Errors []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
|
||||
type MachineResponse struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func hasKeygenErrorCode(body []byte, code string) bool {
|
||||
var resp keygenErrorResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range resp.Errors {
|
||||
if strings.EqualFold(strings.TrimSpace(item.Code), code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string) (*ValidateResponse, error) {
|
||||
return c.ValidateKeyWithMachine(key, fingerprint, "")
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ValidateKeyWithMachine(key, fingerprint, machineID string) (*ValidateResponse, error) {
|
||||
url := c.apiURL("licenses/actions/validate-key")
|
||||
|
||||
meta := map[string]interface{}{
|
||||
@@ -78,6 +125,14 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
|
||||
"fingerprint": fingerprint,
|
||||
}
|
||||
}
|
||||
if machineID != "" {
|
||||
scope, _ := meta["scope"].(map[string]interface{})
|
||||
if scope == nil {
|
||||
scope = make(map[string]interface{})
|
||||
meta["scope"] = scope
|
||||
}
|
||||
scope["machine"] = machineID
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"meta": meta,
|
||||
@@ -103,7 +158,8 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
var valResp ValidateResponse
|
||||
@@ -114,6 +170,42 @@ func (c *KeygenClient) ValidateKeyWithFingerprint(key string, fingerprint string
|
||||
return &valResp, nil
|
||||
}
|
||||
|
||||
func (c *KeygenClient) GetMachineID(fingerprint string) (string, error) {
|
||||
machineURL := c.apiURL("machines/" + url.PathEscape(fingerprint))
|
||||
req, err := http.NewRequest(http.MethodGet, machineURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.api+json")
|
||||
if c.Token != "" {
|
||||
if !strings.HasPrefix(c.Token, "Bearer ") && !strings.HasPrefix(c.Token, "License ") {
|
||||
req.Header.Set("Authorization", "License "+c.Token)
|
||||
} else {
|
||||
req.Header.Set("Authorization", c.Token)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", &APIError{Operation: "retrieve machine", StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
var machineResp MachineResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&machineResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
machineID := strings.TrimSpace(machineResp.Data.ID)
|
||||
if machineID == "" {
|
||||
return "", fmt.Errorf("failed to retrieve machine: empty machine id")
|
||||
}
|
||||
return machineID, nil
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
||||
url := c.apiURL("licenses/actions/validate-key")
|
||||
|
||||
@@ -142,7 +234,8 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("keygen api error: status %d", resp.StatusCode)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &APIError{Operation: "validate license", StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
var valResp ValidateResponse
|
||||
@@ -153,7 +246,7 @@ func (c *KeygenClient) ValidateKey(key string) (*ValidateResponse, error) {
|
||||
return &valResp, nil
|
||||
}
|
||||
|
||||
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
|
||||
func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) (string, error) {
|
||||
url := c.apiURL("machines")
|
||||
|
||||
var reqBody ActivateMachineRequest
|
||||
@@ -177,15 +270,24 @@ func (c *KeygenClient) ActivateMachine(licenseID, fingerprint string) error {
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
return fmt.Errorf("failed to activate machine: status %d, response: %s", resp.StatusCode, string(body))
|
||||
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
|
||||
var machineResp MachineResponse
|
||||
if json.Unmarshal(body, &machineResp) == nil {
|
||||
return strings.TrimSpace(machineResp.Data.ID), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnprocessableEntity && hasKeygenErrorCode(body, "FINGERPRINT_TAKEN") {
|
||||
// Machine activation is idempotent. Keygen scopes fingerprint uniqueness
|
||||
// to the target license, so this means the same machine is already bound.
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return "", &APIError{Operation: "activate machine", StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateKeyWithMachineSendsFingerprintAndMachineScopes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Meta struct {
|
||||
Scope map[string]string `json:"scope"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body.Meta.Scope["fingerprint"] != "fingerprint" {
|
||||
t.Fatalf("fingerprint scope = %q", body.Meta.Scope["fingerprint"])
|
||||
}
|
||||
if body.Meta.Scope["machine"] != "machine-id" {
|
||||
t.Fatalf("machine scope = %q", body.Meta.Scope["machine"])
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"meta":{"valid":true,"code":"VALID"},"data":{"id":"license-id","attributes":{}}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewKeygenClient("account-id", "")
|
||||
client.BaseURL = server.URL
|
||||
validation, err := client.ValidateKeyWithMachine("license-key", "fingerprint", "machine-id")
|
||||
if err != nil || !validation.Meta.Valid {
|
||||
t.Fatalf("ValidateKeyWithMachine() validation=%+v err=%v", validation, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMachineIDRetrievesMachineByFingerprint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/machines/fingerprint") {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "License license-key" {
|
||||
t.Fatalf("authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewKeygenClient("account-id", "license-key")
|
||||
client.BaseURL = server.URL
|
||||
machineID, err := client.GetMachineID("fingerprint")
|
||||
if err != nil || machineID != "machine-id" {
|
||||
t.Fatalf("GetMachineID() = %q, %v", machineID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateMachineTreatsFingerprintTakenAsIdempotent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"FINGERPRINT_TAKEN"},{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewKeygenClient("account-id", "license-key")
|
||||
client.BaseURL = server.URL
|
||||
|
||||
if machineID, err := client.ActivateMachine("license-id", "fingerprint"); err != nil || machineID != "" {
|
||||
t.Fatalf("ActivateMachine() error = %v, want idempotent success", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateMachineReturnsCreatedMachineID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = fmt.Fprint(w, `{"data":{"type":"machines","id":"machine-id"}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewKeygenClient("account-id", "license-key")
|
||||
client.BaseURL = server.URL
|
||||
machineID, err := client.ActivateMachine("license-id", "fingerprint")
|
||||
if err != nil || machineID != "machine-id" {
|
||||
t.Fatalf("ActivateMachine() = %q, %v", machineID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateMachineRejectsMachineLimitWithoutFingerprintTaken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
_, _ = fmt.Fprint(w, `{"errors":[{"code":"MACHINE_LIMIT_EXCEEDED"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewKeygenClient("account-id", "license-key")
|
||||
client.BaseURL = server.URL
|
||||
|
||||
_, err := client.ActivateMachine("license-id", "fingerprint")
|
||||
if err == nil || !strings.Contains(err.Error(), "MACHINE_LIMIT_EXCEEDED") {
|
||||
t.Fatalf("ActivateMachine() error = %v, want machine limit failure", err)
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,27 @@ var publicConfigKeys = map[string]struct{}{
|
||||
"app_favicon": {},
|
||||
"app_bg_image": {},
|
||||
"cloudflare_site_key": {},
|
||||
"is_commercial": {},
|
||||
"hide_footer_brand": {},
|
||||
}
|
||||
|
||||
var sensitiveConfigKeys = map[string]struct{}{
|
||||
"jwt_secret": {},
|
||||
"license_key": {},
|
||||
"license_expiry": {},
|
||||
"license_machine_id": {},
|
||||
"machine_fingerprint": {},
|
||||
"cloudflare_secret_key": {},
|
||||
}
|
||||
|
||||
var systemManagedConfigKeys = map[string]struct{}{
|
||||
"license_key": {},
|
||||
"license_expiry": {},
|
||||
"license_machine_id": {},
|
||||
"is_commercial": {},
|
||||
"machine_fingerprint": {},
|
||||
}
|
||||
|
||||
func PolicyForConfig(name string) ConfigAccessPolicy {
|
||||
if IsPublicConfigKey(name) {
|
||||
return ConfigAccessPublic
|
||||
@@ -43,6 +56,11 @@ func IsSensitiveConfigKey(name string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func IsSystemManagedConfigKey(name string) bool {
|
||||
_, ok := systemManagedConfigKeys[normalizeConfigKey(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func FilterSensitiveConfigs(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return map[string]string{}
|
||||
@@ -57,6 +75,20 @@ func FilterSensitiveConfigs(in map[string]string) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
func FilterBackupConfigs(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for name, value := range in {
|
||||
if IsSensitiveConfigKey(name) || IsSystemManagedConfigKey(name) {
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeConfigKey(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ func TestConfigPolicy(t *testing.T) {
|
||||
{name: "app_favicon is public", key: "app_favicon", want: ConfigAccessPublic},
|
||||
{name: "app_bg_image is public", key: "app_bg_image", want: ConfigAccessPublic},
|
||||
{name: "cloudflare_site_key is public", key: "cloudflare_site_key", want: ConfigAccessPublic},
|
||||
{name: "is_commercial is public", key: "is_commercial", want: ConfigAccessPublic},
|
||||
{name: "hide_footer_brand is public", key: "hide_footer_brand", want: ConfigAccessPublic},
|
||||
{name: "jwt_secret is sensitive", key: "jwt_secret", want: ConfigAccessSensitive},
|
||||
{name: "license_key is sensitive", key: "license_key", want: ConfigAccessSensitive},
|
||||
{name: "cloudflare_secret_key is sensitive", key: "cloudflare_secret_key", want: ConfigAccessSensitive},
|
||||
@@ -29,14 +31,14 @@ func TestConfigPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfigPolicyHelpers(t *testing.T) {
|
||||
publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "cloudflare_site_key"}
|
||||
publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "cloudflare_site_key", "is_commercial", "hide_footer_brand"}
|
||||
for _, key := range publicKeys {
|
||||
if !IsPublicConfigKey(key) {
|
||||
t.Fatalf("expected %q to be public", key)
|
||||
}
|
||||
}
|
||||
|
||||
sensitiveKeys := []string{"jwt_secret", "license_key", "cloudflare_secret_key"}
|
||||
sensitiveKeys := []string{"jwt_secret", "license_key", "license_expiry", "license_machine_id", "machine_fingerprint", "cloudflare_secret_key"}
|
||||
for _, key := range sensitiveKeys {
|
||||
if !IsSensitiveConfigKey(key) {
|
||||
t.Fatalf("expected %q to be sensitive", key)
|
||||
@@ -67,3 +69,30 @@ func TestConfigPolicyHelpers(t *testing.T) {
|
||||
t.Fatal("expected cloudflare_secret_key to be filtered out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterBackupConfigsOmitsLicenseState(t *testing.T) {
|
||||
filtered := FilterBackupConfigs(map[string]string{
|
||||
"app_name": "Brand",
|
||||
"license_key": "secret-license",
|
||||
"license_expiry": "never",
|
||||
"license_machine_id": "machine-id",
|
||||
"is_commercial": "true",
|
||||
"machine_fingerprint": "fingerprint",
|
||||
})
|
||||
if len(filtered) != 1 || filtered["app_name"] != "Brand" {
|
||||
t.Fatalf("unexpected backup configs: %+v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemManagedConfigKeys(t *testing.T) {
|
||||
for _, key := range []string{"license_key", "license_expiry", "license_machine_id", "is_commercial", "machine_fingerprint"} {
|
||||
if !IsSystemManagedConfigKey(key) {
|
||||
t.Fatalf("expected %s to be system managed", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"jwt_secret", "cloudflare_secret_key", "app_name"} {
|
||||
if IsSystemManagedConfigKey(key) {
|
||||
t.Fatalf("did not expect %s to be system managed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +473,14 @@ func seedData(db *gorm.DB) {
|
||||
|
||||
appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000}
|
||||
db.Where("id = ?", 1).FirstOrCreate(&appNameConfig)
|
||||
now := time.Now().UnixMilli()
|
||||
for name, value := range map[string]string{
|
||||
"is_commercial": "false",
|
||||
"hide_footer_brand": "false",
|
||||
} {
|
||||
cfg := model.ViteConfig{Name: name, Value: value, Time: now}
|
||||
db.Where("name = ?", name).FirstOrCreate(&cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── User Queries ────────────────────────────────────────────────────
|
||||
@@ -600,6 +608,23 @@ func (r *Repository) UpsertConfig(name, value string, now int64) error {
|
||||
}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertConfigs(values map[string]string, now int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for name, value := range values {
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "name"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value", "time"}),
|
||||
}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Announcement Queries ────────────────────────────────────────────
|
||||
|
||||
func (r *Repository) GetAnnouncement() (*model.Announcement, error) {
|
||||
@@ -1967,7 +1992,7 @@ func (r *Repository) ExportAll() (*model.BackupData, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export configs failed: %w", err)
|
||||
}
|
||||
backup.Configs = FilterSensitiveConfigs(configs)
|
||||
backup.Configs = FilterBackupConfigs(configs)
|
||||
|
||||
return backup, nil
|
||||
}
|
||||
@@ -2047,7 +2072,7 @@ func (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export configs failed: %w", err)
|
||||
}
|
||||
backup.Configs = FilterSensitiveConfigs(v)
|
||||
backup.Configs = FilterBackupConfigs(v)
|
||||
}
|
||||
return backup, nil
|
||||
}
|
||||
@@ -2839,7 +2864,7 @@ func importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int6
|
||||
}
|
||||
|
||||
func importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {
|
||||
configs = FilterSensitiveConfigs(configs)
|
||||
configs = FilterBackupConfigs(configs)
|
||||
count := 0
|
||||
for name, value := range configs {
|
||||
err := tx.Clauses(clause.OnConflict{
|
||||
|
||||
@@ -97,6 +97,10 @@ func TestExportAllOmitsSensitiveConfigs(t *testing.T) {
|
||||
seedConfig(t, r, "cloudflare_site_key", "site-key")
|
||||
seedConfig(t, r, "jwt_secret", "jwt-secret")
|
||||
seedConfig(t, r, "license_key", "license-secret")
|
||||
seedConfig(t, r, "license_expiry", "2030-01-02T00:00:00.000Z")
|
||||
seedConfig(t, r, "license_machine_id", "machine-id")
|
||||
seedConfig(t, r, "is_commercial", "true")
|
||||
seedConfig(t, r, "machine_fingerprint", "machine-fingerprint")
|
||||
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-secret")
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -117,7 +121,7 @@ func TestExportAllOmitsSensitiveConfigs(t *testing.T) {
|
||||
if backup.Configs["cloudflare_site_key"] != "site-key" {
|
||||
t.Fatalf("expected public config in export, got %+v", backup.Configs)
|
||||
}
|
||||
for _, key := range []string{"jwt_secret", "license_key", "cloudflare_secret_key"} {
|
||||
for _, key := range []string{"jwt_secret", "license_key", "license_expiry", "license_machine_id", "is_commercial", "machine_fingerprint", "cloudflare_secret_key"} {
|
||||
if _, ok := backup.Configs[key]; ok {
|
||||
t.Fatalf("expected %s to be omitted from export, got %+v", key, backup.Configs)
|
||||
}
|
||||
@@ -136,12 +140,20 @@ func TestImportIgnoresSensitiveConfigs(t *testing.T) {
|
||||
seedConfig(t, r, "app_name", "before")
|
||||
seedConfig(t, r, "jwt_secret", "jwt-before")
|
||||
seedConfig(t, r, "license_key", "license-before")
|
||||
seedConfig(t, r, "license_expiry", "expiry-before")
|
||||
seedConfig(t, r, "license_machine_id", "machine-before")
|
||||
seedConfig(t, r, "is_commercial", "true")
|
||||
seedConfig(t, r, "machine_fingerprint", "fingerprint-before")
|
||||
seedConfig(t, r, "cloudflare_secret_key", "cloudflare-before")
|
||||
|
||||
backup := &model.BackupData{Configs: map[string]string{
|
||||
"app_name": "after",
|
||||
"jwt_secret": "jwt-after",
|
||||
"license_key": "license-after",
|
||||
"license_expiry": "expiry-after",
|
||||
"license_machine_id": "machine-after",
|
||||
"is_commercial": "false",
|
||||
"machine_fingerprint": "fingerprint-after",
|
||||
"cloudflare_secret_key": "cloudflare-after",
|
||||
}}
|
||||
|
||||
@@ -156,6 +168,10 @@ func TestImportIgnoresSensitiveConfigs(t *testing.T) {
|
||||
assertConfigValue(t, r, "app_name", "after")
|
||||
assertConfigValue(t, r, "jwt_secret", "jwt-before")
|
||||
assertConfigValue(t, r, "license_key", "license-before")
|
||||
assertConfigValue(t, r, "license_expiry", "expiry-before")
|
||||
assertConfigValue(t, r, "license_machine_id", "machine-before")
|
||||
assertConfigValue(t, r, "is_commercial", "true")
|
||||
assertConfigValue(t, r, "machine_fingerprint", "fingerprint-before")
|
||||
assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -30,9 +31,87 @@ func Global() *Config {
|
||||
globalMux.RLock()
|
||||
defer globalMux.RUnlock()
|
||||
|
||||
cfg := &Config{}
|
||||
*cfg = *global
|
||||
return cfg
|
||||
return cloneConfig(global)
|
||||
}
|
||||
|
||||
// cloneConfig returns a detached snapshot of the runtime config. Runtime
|
||||
// commands mutate slices, pointers and metadata maps, so a shallow struct copy
|
||||
// is not sufficient once readers and persistence run concurrently.
|
||||
func cloneConfig(c *Config) *Config {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
v := cloneConfigValue(reflect.ValueOf(c))
|
||||
if !v.IsValid() || v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
return v.Interface().(*Config)
|
||||
}
|
||||
|
||||
func cloneConfigValue(v reflect.Value) reflect.Value {
|
||||
if !v.IsValid() {
|
||||
return reflect.Value{}
|
||||
}
|
||||
|
||||
switch v.Kind() {
|
||||
case reflect.Interface:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
cloned := cloneConfigValue(v.Elem())
|
||||
out := reflect.New(v.Type()).Elem()
|
||||
if cloned.IsValid() && cloned.Type().AssignableTo(v.Type()) {
|
||||
out.Set(cloned)
|
||||
} else if cloned.IsValid() && cloned.Type().Implements(v.Type()) {
|
||||
out.Set(cloned)
|
||||
} else if cloned.IsValid() {
|
||||
out.Set(cloned)
|
||||
}
|
||||
return out
|
||||
case reflect.Pointer:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.New(v.Type().Elem())
|
||||
out.Elem().Set(cloneConfigValue(v.Elem()))
|
||||
return out
|
||||
case reflect.Map:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.MakeMapWithSize(v.Type(), v.Len())
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
out.SetMapIndex(cloneConfigValue(iter.Key()), cloneConfigValue(iter.Value()))
|
||||
}
|
||||
return out
|
||||
case reflect.Slice:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
out.Index(i).Set(cloneConfigValue(v.Index(i)))
|
||||
}
|
||||
return out
|
||||
case reflect.Array:
|
||||
out := reflect.New(v.Type()).Elem()
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
out.Index(i).Set(cloneConfigValue(v.Index(i)))
|
||||
}
|
||||
return out
|
||||
case reflect.Struct:
|
||||
out := reflect.New(v.Type()).Elem()
|
||||
out.Set(v)
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
if out.Field(i).CanSet() && v.Field(i).CanInterface() {
|
||||
out.Field(i).Set(cloneConfigValue(v.Field(i)))
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func Set(c *Config) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGlobalReturnsDetachedSnapshot(t *testing.T) {
|
||||
original := Global()
|
||||
t.Cleanup(func() { Set(original) })
|
||||
|
||||
Set(&Config{Services: []*ServiceConfig{{
|
||||
Name: "snapshot-service",
|
||||
Metadata: map[string]any{"paused": false},
|
||||
Handler: &HandlerConfig{
|
||||
Type: "relay",
|
||||
Metadata: map[string]any{"retries": 2},
|
||||
},
|
||||
}}})
|
||||
|
||||
snapshot := Global()
|
||||
snapshot.Services[0].Name = "changed"
|
||||
snapshot.Services[0].Metadata["paused"] = true
|
||||
snapshot.Services[0].Handler.Metadata["retries"] = 9
|
||||
|
||||
current := Global()
|
||||
if current.Services[0].Name != "snapshot-service" {
|
||||
t.Fatalf("snapshot mutated global service name: %q", current.Services[0].Name)
|
||||
}
|
||||
if paused, _ := current.Services[0].Metadata["paused"].(bool); paused {
|
||||
t.Fatalf("snapshot mutated global service metadata")
|
||||
}
|
||||
if retries, _ := current.Services[0].Handler.Metadata["retries"].(int); retries != 2 {
|
||||
t.Fatalf("snapshot mutated nested handler metadata: %v", current.Services[0].Handler.Metadata["retries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentGlobalSnapshotAndUpdate(t *testing.T) {
|
||||
original := Global()
|
||||
t.Cleanup(func() { Set(original) })
|
||||
|
||||
Set(&Config{Services: []*ServiceConfig{{
|
||||
Name: "concurrent-service",
|
||||
Metadata: map[string]any{"generation": 0},
|
||||
}}})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for worker := 0; worker < 4; worker++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 500; i++ {
|
||||
if err := OnUpdate(func(c *Config) error {
|
||||
c.Services[0] = &ServiceConfig{
|
||||
Name: "concurrent-service",
|
||||
Metadata: map[string]any{"generation": worker*500 + i},
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Errorf("OnUpdate: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(worker)
|
||||
}
|
||||
|
||||
for i := 0; i < 2000; i++ {
|
||||
if _, err := json.Marshal(Global()); err != nil {
|
||||
t.Fatalf("marshal snapshot: %v", err)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestConcurrentPersistProducesValidConfig(t *testing.T) {
|
||||
original := Global()
|
||||
originalPath := PersistPath()
|
||||
persistMu.Lock()
|
||||
originalEnabled := persistEnable
|
||||
persistMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
Set(original)
|
||||
SetPersistPath(originalPath)
|
||||
persistMu.Lock()
|
||||
persistEnable = originalEnabled
|
||||
persistMu.Unlock()
|
||||
})
|
||||
|
||||
path := filepath.Join(t.TempDir(), "gost.json")
|
||||
SetPersistPath(path)
|
||||
EnablePersist()
|
||||
Set(&Config{Services: []*ServiceConfig{{Name: "persist-service"}}})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for worker := 0; worker < 4; worker++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
if err := OnUpdate(func(c *Config) error {
|
||||
c.Services[0].Metadata = map[string]any{"generation": worker*50 + i}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Errorf("persist update: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(worker)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var persisted Config
|
||||
if err := persisted.ReadFile(path); err != nil {
|
||||
t.Fatalf("read persisted config: %v", err)
|
||||
}
|
||||
if len(persisted.Services) != 1 || persisted.Services[0] == nil || persisted.Services[0].Name != "persist-service" {
|
||||
t.Fatalf("unexpected persisted services: %#v", persisted.Services)
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,9 @@ func EnablePersist() {
|
||||
// persist writes the current global config to the configured file atomically.
|
||||
func persist() error {
|
||||
persistMu.Lock()
|
||||
defer persistMu.Unlock()
|
||||
path := persistPath
|
||||
enabled := persistEnable
|
||||
persistMu.Unlock()
|
||||
|
||||
if !enabled || path == "" {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package socket
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
coreservice "github.com/go-gost/core/service"
|
||||
"github.com/go-gost/x/config"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
|
||||
type blockingCommandService struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
startedOnce sync.Once
|
||||
}
|
||||
|
||||
func (s *blockingCommandService) Serve() error { return nil }
|
||||
func (s *blockingCommandService) Addr() net.Addr { return nil }
|
||||
func (s *blockingCommandService) Close() error {
|
||||
s.startedOnce.Do(func() { close(s.started) })
|
||||
<-s.release
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMutationCommandsAreSerialized(t *testing.T) {
|
||||
mutations := []string{
|
||||
"AddService", "UpdateService", "DeleteService", "PauseService", "ResumeService",
|
||||
"AddChains", "UpdateChains", "DeleteChains",
|
||||
"AddLimiters", "UpdateLimiters", "DeleteLimiters",
|
||||
"AddCLimiters", "UpdateCLimiters", "DeleteCLimiters",
|
||||
"SetProtocol", "UpgradeAgent", "RollbackAgent", "reload",
|
||||
}
|
||||
for _, command := range mutations {
|
||||
if !isMutationCommand(command) {
|
||||
t.Fatalf("expected %s to use the serialized mutation queue", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyCommandsRemainBoundedAsync(t *testing.T) {
|
||||
for _, command := range []string{"TcpPing", "UdpPing", "ServiceMonitorCheck"} {
|
||||
if isMutationCommand(command) {
|
||||
t.Fatalf("expected %s to remain a read-only command", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandResponseTypePreservesRequestContract(t *testing.T) {
|
||||
if got := commandResponseType("UpdateService"); got != "UpdateServiceResponse" {
|
||||
t.Fatalf("unexpected response type: %s", got)
|
||||
}
|
||||
if got := commandResponseType(""); got != "UnknownCommandResponse" {
|
||||
t.Fatalf("unexpected empty command response type: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationQueueExecutesCommandsInArrivalOrder(t *testing.T) {
|
||||
originalConfig := config.Global()
|
||||
t.Cleanup(func() { config.Set(originalConfig) })
|
||||
|
||||
firstName := "mutation_queue_first_tdd"
|
||||
secondName := "mutation_queue_second_tdd"
|
||||
first := &blockingCommandService{started: make(chan struct{}), release: make(chan struct{})}
|
||||
second := &blockingCommandService{started: make(chan struct{}), release: make(chan struct{})}
|
||||
for _, name := range []string{firstName, secondName} {
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-first.release:
|
||||
default:
|
||||
close(first.release)
|
||||
}
|
||||
select {
|
||||
case <-second.release:
|
||||
default:
|
||||
close(second.release)
|
||||
}
|
||||
registry.ServiceRegistry().Unregister(firstName)
|
||||
registry.ServiceRegistry().Unregister(secondName)
|
||||
})
|
||||
if err := registry.ServiceRegistry().Register(firstName, coreservice.Service(first)); err != nil {
|
||||
t.Fatalf("register first service: %v", err)
|
||||
}
|
||||
if err := registry.ServiceRegistry().Register(secondName, coreservice.Service(second)); err != nil {
|
||||
t.Fatalf("register second service: %v", err)
|
||||
}
|
||||
config.Set(&config.Config{Services: []*config.ServiceConfig{{Name: firstName}, {Name: secondName}}})
|
||||
|
||||
reporter := NewWebSocketReporter("", "mutation-queue-test-secret")
|
||||
go reporter.runMutationCommands()
|
||||
t.Cleanup(reporter.Stop)
|
||||
reporter.dispatchCommand(CommandMessage{Type: "DeleteService", Data: map[string]any{"services": []string{firstName}}})
|
||||
reporter.dispatchCommand(CommandMessage{Type: "DeleteService", Data: map[string]any{"services": []string{secondName}}})
|
||||
|
||||
select {
|
||||
case <-first.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first mutation did not start")
|
||||
}
|
||||
select {
|
||||
case <-second.started:
|
||||
t.Fatal("second mutation started before first mutation completed")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(first.release)
|
||||
select {
|
||||
case <-second.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("second mutation did not start after first mutation completed")
|
||||
}
|
||||
close(second.release)
|
||||
}
|
||||
+82
-18
@@ -15,6 +15,11 @@ import (
|
||||
xservice "github.com/go-gost/x/service"
|
||||
)
|
||||
|
||||
type serviceReplacement struct {
|
||||
config config.ServiceConfig
|
||||
oldConfig *config.ServiceConfig
|
||||
}
|
||||
|
||||
func createServices(req createServicesRequest) error {
|
||||
|
||||
if len(req.Data) == 0 {
|
||||
@@ -95,11 +100,11 @@ func updateServices(req updateServicesRequest) error {
|
||||
req.Data[i].Name = name
|
||||
}
|
||||
|
||||
// 第二阶段:逐个更新服务(Upsert模式:存在则更新,不存在则创建)
|
||||
changedServices := make([]struct {
|
||||
config config.ServiceConfig
|
||||
service service.Service
|
||||
}, 0, len(req.Data))
|
||||
// 第二阶段:逐个更新服务(Upsert模式:存在则更新,不存在则创建)。
|
||||
// 配置变更命令由 WebSocket reporter 串行调度,但这里仍保留完整回滚,
|
||||
// 避免新配置解析或监听失败后旧服务永久消失。
|
||||
originalConfig := config.Global()
|
||||
changedServices := make([]serviceReplacement, 0, len(req.Data))
|
||||
for i := range req.Data {
|
||||
serviceConfig := &req.Data[i]
|
||||
name := serviceConfig.Name
|
||||
@@ -107,33 +112,48 @@ func updateServices(req updateServicesRequest) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. 获取旧服务
|
||||
old := registry.ServiceRegistry().Get(name)
|
||||
var oldConfig *config.ServiceConfig
|
||||
if originalConfig != nil {
|
||||
for _, current := range originalConfig.Services {
|
||||
if current != nil && strings.TrimSpace(current.Name) == name {
|
||||
oldConfig = current
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 关闭旧服务 (如果存在)
|
||||
if old != nil {
|
||||
// 3. 从注册表移除旧服务;registry 会负责关闭旧服务。
|
||||
// 1. 关闭并移除旧服务(如果存在)。同名监听必须先释放端口,
|
||||
// 才能创建新 listener。
|
||||
if registry.ServiceRegistry().Get(name) != nil {
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
}
|
||||
|
||||
// 4. 解析新服务配置
|
||||
// 2. 解析新服务配置。
|
||||
svc, err := parser.ParseService(serviceConfig)
|
||||
if err != nil {
|
||||
rollbackErr := restoreServiceRuntime(name, oldConfig)
|
||||
rollbackErr = errors.Join(rollbackErr, rollbackServiceReplacements(changedServices))
|
||||
if rollbackErr != nil {
|
||||
return fmt.Errorf("create service %s failed: %v; restore previous service failed: %w", name, err, rollbackErr)
|
||||
}
|
||||
return errors.New("create service " + name + " failed: " + err.Error())
|
||||
}
|
||||
changedServices = append(changedServices, struct {
|
||||
config config.ServiceConfig
|
||||
service service.Service
|
||||
}{*serviceConfig, svc})
|
||||
|
||||
// 5. 注册新服务
|
||||
// 3. 注册并启动新服务。
|
||||
if err := registry.ServiceRegistry().Register(name, svc); err != nil {
|
||||
svc.Close()
|
||||
rollbackErr := restoreServiceRuntime(name, oldConfig)
|
||||
rollbackErr = errors.Join(rollbackErr, rollbackServiceReplacements(changedServices))
|
||||
if rollbackErr != nil {
|
||||
return fmt.Errorf("service %s already exists; restore previous service failed: %w", name, rollbackErr)
|
||||
}
|
||||
return errors.New("service " + name + " already exists")
|
||||
}
|
||||
|
||||
// 6. 启动新服务
|
||||
go svc.Serve()
|
||||
changedServices = append(changedServices, serviceReplacement{
|
||||
config: *serviceConfig,
|
||||
oldConfig: oldConfig,
|
||||
})
|
||||
}
|
||||
if len(changedServices) == 0 {
|
||||
return nil
|
||||
@@ -158,12 +178,56 @@ func updateServices(req updateServicesRequest) error {
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
config.Set(originalConfig)
|
||||
if rollbackErr := rollbackServiceReplacements(changedServices); rollbackErr != nil {
|
||||
return fmt.Errorf("%w; restore previous services failed: %v", err, rollbackErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreServiceRuntime(name string, serviceConfig *config.ServiceConfig) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || serviceConfig == nil {
|
||||
return nil
|
||||
}
|
||||
if registry.ServiceRegistry().Get(name) != nil {
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
}
|
||||
|
||||
cfgCopy := *serviceConfig
|
||||
cfgCopy.Name = name
|
||||
svc, err := parser.ParseService(&cfgCopy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registry.ServiceRegistry().Register(name, svc); err != nil {
|
||||
svc.Close()
|
||||
return err
|
||||
}
|
||||
go svc.Serve()
|
||||
return nil
|
||||
}
|
||||
|
||||
func rollbackServiceReplacements(replacements []serviceReplacement) error {
|
||||
var rollbackErr error
|
||||
for i := len(replacements) - 1; i >= 0; i-- {
|
||||
name := strings.TrimSpace(replacements[i].config.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if registry.ServiceRegistry().Get(name) != nil {
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
}
|
||||
if err := restoreServiceRuntime(name, replacements[i].oldConfig); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, fmt.Errorf("restore service %s: %w", name, err))
|
||||
}
|
||||
}
|
||||
return rollbackErr
|
||||
}
|
||||
|
||||
func serviceConfigUnchanged(name string, next config.ServiceConfig) bool {
|
||||
cfg := config.Global()
|
||||
if cfg == nil {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
corelogger "github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/service"
|
||||
"github.com/go-gost/x/config"
|
||||
_ "github.com/go-gost/x/handler/auto"
|
||||
_ "github.com/go-gost/x/listener/tcp"
|
||||
xlogger "github.com/go-gost/x/logger"
|
||||
"github.com/go-gost/x/registry"
|
||||
)
|
||||
@@ -15,6 +17,43 @@ type recordingService struct {
|
||||
closed int
|
||||
}
|
||||
|
||||
func TestUpdateServicesParseFailureRestoresPreviousRuntime(t *testing.T) {
|
||||
corelogger.SetDefault(xlogger.Nop())
|
||||
|
||||
name := "restore_after_failed_update_tdd"
|
||||
existing := &recordingService{}
|
||||
registry.ServiceRegistry().Unregister(name)
|
||||
t.Cleanup(func() { registry.ServiceRegistry().Unregister(name) })
|
||||
if err := registry.ServiceRegistry().Register(name, service.Service(existing)); err != nil {
|
||||
t.Fatalf("register existing service: %v", err)
|
||||
}
|
||||
|
||||
originalConfig := config.Global()
|
||||
t.Cleanup(func() { config.Set(originalConfig) })
|
||||
serviceConfig := config.ServiceConfig{Name: name, Addr: "127.0.0.1:0"}
|
||||
config.Set(&config.Config{Services: []*config.ServiceConfig{&serviceConfig}})
|
||||
|
||||
invalid := serviceConfig
|
||||
invalid.Listener = &config.ListenerConfig{Type: "listener-does-not-exist"}
|
||||
if err := updateServices(updateServicesRequest{Data: []config.ServiceConfig{invalid}}); err == nil {
|
||||
t.Fatalf("expected invalid service update to fail")
|
||||
}
|
||||
if existing.closed != 1 {
|
||||
t.Fatalf("expected old runtime to be closed once, got %d", existing.closed)
|
||||
}
|
||||
if registry.ServiceRegistry().Get(name) == nil {
|
||||
t.Fatalf("expected previous runtime to be restored")
|
||||
}
|
||||
|
||||
cfg := config.Global()
|
||||
if len(cfg.Services) != 1 || cfg.Services[0] == nil || cfg.Services[0].Name != name {
|
||||
t.Fatalf("expected previous config to remain, got %#v", cfg.Services)
|
||||
}
|
||||
if cfg.Services[0].Listener != nil {
|
||||
t.Fatalf("expected previous listener config to remain unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *recordingService) Serve() error { return nil }
|
||||
func (s *recordingService) Addr() net.Addr { return nil }
|
||||
func (s *recordingService) Close() error {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -152,6 +153,8 @@ const (
|
||||
maxBackoff = 2 * time.Minute // 重连最大退避
|
||||
defaultMetricReportInterval = 5 * time.Second
|
||||
maxConcurrentTCPPings = 8
|
||||
maxConcurrentReadCommands = 16
|
||||
maxQueuedMutationCommands = 256
|
||||
)
|
||||
|
||||
type WebSocketReporter struct {
|
||||
@@ -174,6 +177,8 @@ type WebSocketReporter struct {
|
||||
connMutex sync.Mutex // 连接状态锁
|
||||
aesCrypto *crypto.AESCrypto // AES加密器
|
||||
tcpPingSem chan struct{} // 限制诊断探测并发,避免离线目标耗尽连接
|
||||
readCommandSem chan struct{} // 限制只读命令并发,避免诊断请求耗尽资源
|
||||
mutationQueue chan CommandMessage
|
||||
}
|
||||
|
||||
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
|
||||
@@ -204,6 +209,8 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
|
||||
connecting: false,
|
||||
aesCrypto: aesCrypto,
|
||||
tcpPingSem: make(chan struct{}, maxConcurrentTCPPings),
|
||||
readCommandSem: make(chan struct{}, maxConcurrentReadCommands),
|
||||
mutationQueue: make(chan CommandMessage, maxQueuedMutationCommands),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +238,7 @@ func (w *WebSocketReporter) releaseTCPPingSlot() {
|
||||
|
||||
// Start 启动WebSocket报告器
|
||||
func (w *WebSocketReporter) Start() {
|
||||
go w.runMutationCommands()
|
||||
go w.run()
|
||||
}
|
||||
|
||||
@@ -777,8 +785,7 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
}
|
||||
|
||||
if cmdMsg.Type != "call" {
|
||||
// 所有命令统一异步执行,避免阻塞消息接收循环
|
||||
go w.routeCommand(cmdMsg)
|
||||
w.dispatchCommand(cmdMsg)
|
||||
}
|
||||
} else {
|
||||
// 处理普通消息
|
||||
@@ -789,8 +796,7 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
return
|
||||
}
|
||||
if cmdMsg.Type != "call" {
|
||||
// 所有命令统一异步执行,避免阻塞消息接收循环
|
||||
go w.routeCommand(cmdMsg)
|
||||
w.dispatchCommand(cmdMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,6 +805,86 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchCommand keeps all runtime mutations ordered while allowing bounded
|
||||
// concurrency for read-only diagnostics. Mutations share process-wide
|
||||
// registries and configuration, so running them concurrently can corrupt the
|
||||
// persisted config or interleave service lifecycle operations.
|
||||
func (w *WebSocketReporter) dispatchCommand(cmd CommandMessage) {
|
||||
if isMutationCommand(cmd.Type) {
|
||||
select {
|
||||
case w.mutationQueue <- cmd:
|
||||
case <-w.ctx.Done():
|
||||
w.sendCommandFailure(cmd, "Agent is shutting down")
|
||||
default:
|
||||
w.sendCommandFailure(cmd, "运行时配置命令队列已满,请稍后重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case w.readCommandSem <- struct{}{}:
|
||||
go func() {
|
||||
defer func() { <-w.readCommandSem }()
|
||||
w.routeCommandSafely(cmd)
|
||||
}()
|
||||
case <-w.ctx.Done():
|
||||
w.sendCommandFailure(cmd, "Agent is shutting down")
|
||||
default:
|
||||
w.sendCommandFailure(cmd, "只读命令并发过多,请稍后重试")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) runMutationCommands() {
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
case cmd := <-w.mutationQueue:
|
||||
w.routeCommandSafely(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) routeCommandSafely(cmd CommandMessage) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
fmt.Printf("❌ 命令处理 panic: type=%s panic=%v\n%s", cmd.Type, recovered, debug.Stack())
|
||||
w.sendCommandFailure(cmd, fmt.Sprintf("命令处理异常: %v", recovered))
|
||||
}
|
||||
}()
|
||||
w.routeCommand(cmd)
|
||||
}
|
||||
|
||||
func (w *WebSocketReporter) sendCommandFailure(cmd CommandMessage, message string) {
|
||||
w.sendResponse(CommandResponse{
|
||||
Type: commandResponseType(cmd.Type),
|
||||
Success: false,
|
||||
Message: message,
|
||||
RequestId: cmd.RequestId,
|
||||
})
|
||||
}
|
||||
|
||||
func commandResponseType(commandType string) string {
|
||||
commandType = strings.TrimSpace(commandType)
|
||||
if commandType == "" {
|
||||
return "UnknownCommandResponse"
|
||||
}
|
||||
return commandType + "Response"
|
||||
}
|
||||
|
||||
func isMutationCommand(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice",
|
||||
"addchains", "updatechains", "deletechains",
|
||||
"addlimiters", "updatelimiters", "deletelimiters",
|
||||
"addclimiters", "updateclimiters", "deleteclimiters",
|
||||
"setprotocol", "upgradeagent", "rollbackagent", "reload":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// routeCommand 路由命令到对应的处理函数
|
||||
func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
|
||||
jsonBytes, errs := json.Marshal(cmd)
|
||||
|
||||
@@ -405,6 +405,7 @@ After=network.target
|
||||
[Service]
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$INSTALL_DIR/flux_agent
|
||||
Environment=GODEBUG=disablethp=1
|
||||
Restart=on-failure
|
||||
StandardOutput=null
|
||||
StandardError=null
|
||||
|
||||
@@ -277,6 +277,8 @@ EOF
|
||||
local expected=$'{\n "addr": "panel\\"addr",\n "secret": "sec\\\\ret\\"1"\n}'
|
||||
|
||||
assert_equals "$expected" "$actual" "install_flux_agent should JSON-escape config values"
|
||||
grep -Fqx 'Environment=GODEBUG=disablethp=1' "$FLUX_AGENT_SYSTEMD_SERVICE_FILE" || \
|
||||
fail "systemd service should disable transparent huge pages for the Go heap"
|
||||
)
|
||||
|
||||
test_install_script_bootstraps_bash_for_alpine() (
|
||||
|
||||
@@ -14,10 +14,14 @@ const PUBLIC_BRAND_CONFIG_KEYS = [
|
||||
"app_logo",
|
||||
"app_favicon",
|
||||
"app_bg_image",
|
||||
"is_commercial",
|
||||
"hide_footer_brand",
|
||||
] as const;
|
||||
const SENSITIVE_CONFIG_KEYS = new Set([
|
||||
"jwt_secret",
|
||||
"license_key",
|
||||
"license_machine_id",
|
||||
"machine_fingerprint",
|
||||
"cloudflare_secret_key",
|
||||
]);
|
||||
const GITHUB_REPO =
|
||||
@@ -130,15 +134,15 @@ const getInitialConfig = () => {
|
||||
|
||||
if (cachedAppName) {
|
||||
return {
|
||||
name: cachedAppName,
|
||||
name: isCommercial ? cachedAppName : "FLVX",
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
app_logo: cachedAppLogo,
|
||||
app_favicon: cachedAppFavicon,
|
||||
app_logo: isCommercial ? cachedAppLogo : "",
|
||||
app_favicon: isCommercial ? cachedAppFavicon : "",
|
||||
app_bg_image: cachedAppBgImage,
|
||||
is_commercial: isCommercial,
|
||||
hide_footer_brand: hideFooterBrand,
|
||||
hide_footer_brand: isCommercial && hideFooterBrand,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,11 +151,11 @@ const getInitialConfig = () => {
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
app_logo: cachedAppLogo,
|
||||
app_favicon: cachedAppFavicon,
|
||||
app_logo: isCommercial ? cachedAppLogo : "",
|
||||
app_favicon: isCommercial ? cachedAppFavicon : "",
|
||||
app_bg_image: cachedAppBgImage,
|
||||
is_commercial: isCommercial,
|
||||
hide_footer_brand: hideFooterBrand,
|
||||
hide_footer_brand: isCommercial && hideFooterBrand,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -379,6 +383,12 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
||||
"app_bg_image",
|
||||
);
|
||||
|
||||
const resolvedCommercial = Object.prototype.hasOwnProperty.call(
|
||||
resolvedConfigMap,
|
||||
"is_commercial",
|
||||
)
|
||||
? resolvedConfigMap.is_commercial === "true"
|
||||
: siteConfig.is_commercial;
|
||||
const appName = hasAppName
|
||||
? String(resolvedConfigMap.app_name || "").trim()
|
||||
: siteConfig.name;
|
||||
@@ -392,23 +402,22 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
||||
? String(resolvedConfigMap.app_bg_image || "").trim()
|
||||
: (siteConfig.app_bg_image || "").trim();
|
||||
|
||||
if (appName && appName !== siteConfig.name) {
|
||||
siteConfig.name = appName;
|
||||
}
|
||||
|
||||
siteConfig.app_logo = appLogo;
|
||||
siteConfig.app_favicon = appFavicon;
|
||||
siteConfig.name = resolvedCommercial && appName ? appName : "FLVX";
|
||||
siteConfig.app_logo = resolvedCommercial ? appLogo : "";
|
||||
siteConfig.app_favicon = resolvedCommercial ? appFavicon : "";
|
||||
siteConfig.app_bg_image = appBgImage;
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(resolvedConfigMap, "is_commercial")
|
||||
) {
|
||||
siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true";
|
||||
siteConfig.is_commercial = resolvedCommercial;
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(resolvedConfigMap, "hide_footer_brand")
|
||||
) {
|
||||
siteConfig.hide_footer_brand =
|
||||
resolvedConfigMap.hide_footer_brand === "true";
|
||||
resolvedCommercial && resolvedConfigMap.hide_footer_brand === "true";
|
||||
} else if (!resolvedCommercial) {
|
||||
siteConfig.hide_footer_brand = false;
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
|
||||
Reference in New Issue
Block a user