mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 23:56:36 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40e96f3592 | |||
| 0e24b53a5b | |||
| a820c49c94 | |||
| 538e64ffc0 |
+1
-1
@@ -70,7 +70,7 @@ Alpine Linux 最小化安装若未包含 `curl`,可使用系统自带的 `wget
|
||||
wget -O install.sh https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
脚本会在 Alpine 上自动安装 Bash,并使用 OpenRC 注册、启动和管理 `flux_agent` 服务;其他受支持的 Linux 发行版继续使用 systemd。
|
||||
脚本会在 Alpine 上自动安装 Bash、`curl` 和 CA 证书,并使用 OpenRC 注册、启动和管理 `flux_agent` 服务;其他受支持的 Linux 发行版继续使用 systemd。
|
||||
|
||||
**安装过程中会提示输入:**
|
||||
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
|
||||
|
||||
@@ -659,9 +659,21 @@ func (h *Handler) sendDeleteOrphanedForwardService(nodeID int64, serviceName str
|
||||
}
|
||||
|
||||
func (h *Handler) speedLimiterExists(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
const forwardRulePrefix = "rule_traffic_limit_"
|
||||
if strings.HasPrefix(name, forwardRulePrefix) {
|
||||
forwardID, err := strconv.ParseInt(strings.TrimPrefix(name, forwardRulePrefix), 10, 64)
|
||||
if err != nil || forwardID <= 0 {
|
||||
return false
|
||||
}
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
return err == nil && forward != nil && forward.IPSpeedID.Valid && forward.IPSpeedID.Int64 > 0
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(name, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestSpeedLimiterExistsPreservesForwardRuleLimiter(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx, ip_speed_id)
|
||||
VALUES(8, 1, 'user', 'forward', 1, '127.0.0.1:80', 'fifo', 0, 0, 1, 1, 1, 0, 3),
|
||||
(9, 1, 'user', 'forward-without-ip-limit', 1, '127.0.0.1:81', 'fifo', 0, 0, 1, 1, 1, 0, NULL)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
if !h.speedLimiterExists("rule_traffic_limit_8") {
|
||||
t.Fatal("expected runtime limiter for existing forward to be preserved")
|
||||
}
|
||||
if h.speedLimiterExists("rule_traffic_limit_9") {
|
||||
t.Fatal("expected runtime limiter for forward without per-IP speed limit to be treated as orphaned")
|
||||
}
|
||||
if h.speedLimiterExists("rule_traffic_limit_10") {
|
||||
t.Fatal("expected runtime limiter for missing forward to be treated as orphaned")
|
||||
}
|
||||
if h.speedLimiterExists("rule_traffic_limit_invalid") {
|
||||
t.Fatal("expected malformed runtime limiter name to be treated as orphaned")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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"
|
||||
@@ -174,14 +186,6 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
+45
-5
@@ -64,6 +64,38 @@ SERVICE_MANAGER="${SERVICE_MANAGER:-}"
|
||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||
PROXY_URL="${PROXY_URL:-}"
|
||||
|
||||
ensure_alpine_runtime_dependencies() {
|
||||
[[ -f /etc/alpine-release ]] || return 0
|
||||
|
||||
local missing_packages=()
|
||||
local privileged_command=""
|
||||
|
||||
command -v curl >/dev/null 2>&1 || missing_packages+=(curl)
|
||||
[[ -f /etc/ssl/certs/ca-certificates.crt ]] || missing_packages+=(ca-certificates)
|
||||
|
||||
if [[ ${#missing_packages[@]} -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
privileged_command="sudo"
|
||||
elif command -v doas >/dev/null 2>&1; then
|
||||
privileged_command="doas"
|
||||
else
|
||||
echo "❌ Alpine 安装需要 root 权限,或已配置 sudo/doas 来安装依赖: ${missing_packages[*]}。" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "📦 Alpine 缺少运行依赖,正在安装: ${missing_packages[*]}"
|
||||
if [[ -n "$privileged_command" ]]; then
|
||||
"$privileged_command" apk add --no-cache "${missing_packages[@]}"
|
||||
else
|
||||
apk add --no-cache "${missing_packages[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
# 镜像加速
|
||||
maybe_proxy_url() {
|
||||
local url="$1"
|
||||
@@ -169,6 +201,8 @@ build_download_url() {
|
||||
}
|
||||
|
||||
ensure_download_url_initialized() {
|
||||
ensure_alpine_runtime_dependencies || return 1
|
||||
|
||||
if [[ -n "${DOWNLOAD_URL:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -299,11 +333,16 @@ ensure_service_manager() {
|
||||
esac
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
|
||||
# Alpine uses OpenRC even if a systemctl compatibility command happens to be installed.
|
||||
if [[ -f /etc/alpine-release ]]; then
|
||||
if command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then
|
||||
SERVICE_MANAGER="openrc"
|
||||
return 0
|
||||
fi
|
||||
elif command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
|
||||
SERVICE_MANAGER="systemd"
|
||||
return 0
|
||||
fi
|
||||
if command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then
|
||||
elif command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then
|
||||
SERVICE_MANAGER="openrc"
|
||||
return 0
|
||||
fi
|
||||
@@ -501,7 +540,8 @@ cleanup_legacy_gost_installation() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if systemctl list-units --full -all 2>/dev/null | grep -Fq "gost.service"; then
|
||||
if [[ "$SERVICE_MANAGER" == "systemd" ]] && \
|
||||
systemctl list-units --full -all 2>/dev/null | grep -Fq "gost.service"; then
|
||||
systemctl stop gost 2>/dev/null || true
|
||||
systemctl disable gost 2>/dev/null || true
|
||||
fi
|
||||
@@ -520,7 +560,7 @@ cleanup_legacy_gost_installation() {
|
||||
rm -f "$LEGACY_GOST_CONFIG_DIR/gost"
|
||||
fi
|
||||
|
||||
if [[ "$removed_service_file" == "1" ]]; then
|
||||
if [[ "$removed_service_file" == "1" && "$SERVICE_MANAGER" == "systemd" ]]; then
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -399,6 +399,7 @@ test_cleanup_legacy_gost_installation_removes_service_and_binary() (
|
||||
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
|
||||
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
|
||||
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
|
||||
SERVICE_MANAGER="systemd"
|
||||
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<EOF
|
||||
[Unit]
|
||||
Description=Gost Proxy Service
|
||||
@@ -442,6 +443,7 @@ test_cleanup_legacy_gost_installation_preserves_unrelated_gost() (
|
||||
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
|
||||
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
|
||||
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
|
||||
SERVICE_MANAGER="systemd"
|
||||
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<'EOF'
|
||||
[Unit]
|
||||
Description=Unrelated Gost Service
|
||||
@@ -469,6 +471,35 @@ EOF
|
||||
[[ "$systemctl_calls" != *"disable gost"* ]] || fail "cleanup_legacy_gost_installation should not disable unrelated gost services"
|
||||
)
|
||||
|
||||
test_cleanup_legacy_gost_installation_skips_systemd_on_openrc() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/install.sh"
|
||||
|
||||
LEGACY_GOST_SERVICE_FILE_ETC=$(mktemp)
|
||||
LEGACY_GOST_SERVICE_FILE_LIB=$(mktemp -u)
|
||||
LEGACY_GOST_SERVICE_FILE_USR_LIB=$(mktemp -u)
|
||||
LEGACY_GOST_CONFIG_DIR=$(mktemp -d)
|
||||
SERVICE_MANAGER="openrc"
|
||||
cat > "$LEGACY_GOST_SERVICE_FILE_ETC" <<EOF
|
||||
[Unit]
|
||||
WorkingDirectory=$LEGACY_GOST_CONFIG_DIR
|
||||
ExecStart=$LEGACY_GOST_CONFIG_DIR/gost
|
||||
EOF
|
||||
: > "$LEGACY_GOST_CONFIG_DIR/config.json"
|
||||
: > "$LEGACY_GOST_CONFIG_DIR/gost.json"
|
||||
|
||||
local systemctl_calls=""
|
||||
systemctl() {
|
||||
systemctl_calls+=$'\n'"$*"
|
||||
return 1
|
||||
}
|
||||
|
||||
cleanup_legacy_gost_installation >/dev/null
|
||||
|
||||
[[ -z "$systemctl_calls" ]] || fail "OpenRC cleanup should not invoke systemctl"
|
||||
[[ ! -e "$LEGACY_GOST_SERVICE_FILE_ETC" ]] || fail "OpenRC cleanup should still remove the legacy service file"
|
||||
)
|
||||
|
||||
test_install_script_accepts_proxy_url_env_without_prompt() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/install.sh"
|
||||
@@ -625,6 +656,7 @@ test_install_flux_agent_uses_openrc
|
||||
test_remove_flux_agent_service_uses_openrc
|
||||
test_cleanup_legacy_gost_installation_removes_service_and_binary
|
||||
test_cleanup_legacy_gost_installation_preserves_unrelated_gost
|
||||
test_cleanup_legacy_gost_installation_skips_systemd_on_openrc
|
||||
test_install_script_accepts_proxy_url_env_without_prompt
|
||||
test_panel_install_script_can_disable_proxy
|
||||
test_panel_install_script_recomputes_compose_urls_after_prompt
|
||||
|
||||
@@ -28,12 +28,13 @@ function DialogClose({
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/30 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
@@ -42,7 +43,9 @@ function DialogOverlay({
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
|
||||
@@ -49,6 +49,41 @@ function useModalContext() {
|
||||
return React.useContext(ModalContext);
|
||||
}
|
||||
|
||||
interface ScrollPosition {
|
||||
element: HTMLElement | null;
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
function captureScrollPositions(): ScrollPosition[] {
|
||||
const positions: ScrollPosition[] = [
|
||||
{ element: null, left: window.scrollX, top: window.scrollY },
|
||||
];
|
||||
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll<HTMLElement>("main, [data-scroll-container]"),
|
||||
)) {
|
||||
positions.push({
|
||||
element,
|
||||
left: element.scrollLeft,
|
||||
top: element.scrollTop,
|
||||
});
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function restoreScrollPositions(positions: ScrollPosition[]) {
|
||||
for (const position of positions) {
|
||||
if (position.element) {
|
||||
position.element.scrollLeft = position.left;
|
||||
position.element.scrollTop = position.top;
|
||||
} else {
|
||||
window.scrollTo(position.left, position.top);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ModalSize = "sm" | "md" | "lg" | "xl" | "2xl" | "4xl" | "full";
|
||||
|
||||
function mapSize(size: ModalSize | undefined) {
|
||||
@@ -97,6 +132,46 @@ export function Modal({
|
||||
scrollBehavior,
|
||||
size,
|
||||
}: ModalProps) {
|
||||
const previousScrollPositionsRef = React.useRef<ScrollPosition[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Radix focus management and scroll locking can move an ancestor scroll
|
||||
// container when a modal is opened from a card/grid item. Capture the
|
||||
// current positions before the open render and restore them after focus
|
||||
// settles so opening a modal never changes the page position.
|
||||
React.useLayoutEffect(() => {
|
||||
return () => {
|
||||
if (!isOpen) {
|
||||
previousScrollPositionsRef.current = captureScrollPositions();
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const positions = previousScrollPositionsRef.current;
|
||||
|
||||
if (!isOpen || !positions) {
|
||||
return;
|
||||
}
|
||||
|
||||
restoreScrollPositions(positions);
|
||||
let nestedFrame = 0;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
restoreScrollPositions(positions);
|
||||
nestedFrame = window.requestAnimationFrame(() =>
|
||||
restoreScrollPositions(positions),
|
||||
);
|
||||
});
|
||||
|
||||
previousScrollPositionsRef.current = null;
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.cancelAnimationFrame(nestedFrame);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
onOpenChange?.(open);
|
||||
if (!open) {
|
||||
|
||||
Reference in New Issue
Block a user