mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 04:40:52 +08:00
Merge pull request #1492 from router-for-me/management
fix(management): ensure management.html is available synchronously and improve asset sync handling
This commit is contained in:
2
go.mod
2
go.mod
@@ -22,6 +22,7 @@ require (
|
|||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.45.0
|
||||||
golang.org/x/net v0.47.0
|
golang.org/x/net v0.47.0
|
||||||
golang.org/x/oauth2 v0.30.0
|
golang.org/x/oauth2 v0.30.0
|
||||||
|
golang.org/x/sync v0.18.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -69,7 +70,6 @@ require (
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
|
|||||||
@@ -655,14 +655,17 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
|
|||||||
|
|
||||||
if _, err := os.Stat(filePath); err != nil {
|
if _, err := os.Stat(filePath); err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository)
|
// Synchronously ensure management.html is available with a detached context.
|
||||||
c.AbortWithStatus(http.StatusNotFound)
|
// Control panel bootstrap should not be canceled by client disconnects.
|
||||||
|
if !managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.WithError(err).Error("failed to stat management control panel asset")
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithError(err).Error("failed to stat management control panel asset")
|
|
||||||
c.AbortWithStatus(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.File(filePath)
|
c.File(filePath)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -41,6 +42,7 @@ var (
|
|||||||
currentConfigPtr atomic.Pointer[config.Config]
|
currentConfigPtr atomic.Pointer[config.Config]
|
||||||
schedulerOnce sync.Once
|
schedulerOnce sync.Once
|
||||||
schedulerConfigPath atomic.Value
|
schedulerConfigPath atomic.Value
|
||||||
|
sfGroup singleflight.Group
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetCurrentConfig stores the latest configuration snapshot for management asset decisions.
|
// SetCurrentConfig stores the latest configuration snapshot for management asset decisions.
|
||||||
@@ -171,8 +173,8 @@ func FilePath(configFilePath string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
|
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
|
||||||
// The function is designed to run in a background goroutine and will never panic.
|
// It coalesces concurrent sync attempts and returns whether the asset exists after the sync attempt.
|
||||||
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) {
|
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) bool {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
@@ -180,91 +182,97 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL
|
|||||||
staticDir = strings.TrimSpace(staticDir)
|
staticDir = strings.TrimSpace(staticDir)
|
||||||
if staticDir == "" {
|
if staticDir == "" {
|
||||||
log.Debug("management asset sync skipped: empty static directory")
|
log.Debug("management asset sync skipped: empty static directory")
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
lastUpdateCheckMu.Lock()
|
|
||||||
now := time.Now()
|
|
||||||
timeSinceLastAttempt := now.Sub(lastUpdateCheckTime)
|
|
||||||
if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval {
|
|
||||||
lastUpdateCheckMu.Unlock()
|
|
||||||
log.Debugf(
|
|
||||||
"management asset sync skipped by throttle: last attempt %v ago (interval %v)",
|
|
||||||
timeSinceLastAttempt.Round(time.Second),
|
|
||||||
managementSyncMinInterval,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastUpdateCheckTime = now
|
|
||||||
lastUpdateCheckMu.Unlock()
|
|
||||||
|
|
||||||
localPath := filepath.Join(staticDir, managementAssetName)
|
localPath := filepath.Join(staticDir, managementAssetName)
|
||||||
localFileMissing := false
|
|
||||||
if _, errStat := os.Stat(localPath); errStat != nil {
|
_, _, _ = sfGroup.Do(localPath, func() (interface{}, error) {
|
||||||
if errors.Is(errStat, os.ErrNotExist) {
|
lastUpdateCheckMu.Lock()
|
||||||
localFileMissing = true
|
now := time.Now()
|
||||||
} else {
|
timeSinceLastAttempt := now.Sub(lastUpdateCheckTime)
|
||||||
log.WithError(errStat).Debug("failed to stat local management asset")
|
if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval {
|
||||||
|
lastUpdateCheckMu.Unlock()
|
||||||
|
log.Debugf(
|
||||||
|
"management asset sync skipped by throttle: last attempt %v ago (interval %v)",
|
||||||
|
timeSinceLastAttempt.Round(time.Second),
|
||||||
|
managementSyncMinInterval,
|
||||||
|
)
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
}
|
lastUpdateCheckTime = now
|
||||||
|
lastUpdateCheckMu.Unlock()
|
||||||
|
|
||||||
if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil {
|
localFileMissing := false
|
||||||
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
|
if _, errStat := os.Stat(localPath); errStat != nil {
|
||||||
return
|
if errors.Is(errStat, os.ErrNotExist) {
|
||||||
}
|
localFileMissing = true
|
||||||
|
} else {
|
||||||
releaseURL := resolveReleaseURL(panelRepository)
|
log.WithError(errStat).Debug("failed to stat local management asset")
|
||||||
client := newHTTPClient(proxyURL)
|
|
||||||
|
|
||||||
localHash, err := fileSHA256(localPath)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
log.WithError(err).Debug("failed to read local management asset hash")
|
|
||||||
}
|
|
||||||
localHash = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
asset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL)
|
|
||||||
if err != nil {
|
|
||||||
if localFileMissing {
|
|
||||||
log.WithError(err).Warn("failed to fetch latest management release information, trying fallback page")
|
|
||||||
if ensureFallbackManagementHTML(ctx, client, localPath) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
log.WithError(err).Warn("failed to fetch latest management release information")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
|
if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil {
|
||||||
log.Debug("management asset is already up to date")
|
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
|
||||||
return
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)
|
releaseURL := resolveReleaseURL(panelRepository)
|
||||||
if err != nil {
|
client := newHTTPClient(proxyURL)
|
||||||
if localFileMissing {
|
|
||||||
log.WithError(err).Warn("failed to download management asset, trying fallback page")
|
localHash, err := fileSHA256(localPath)
|
||||||
if ensureFallbackManagementHTML(ctx, client, localPath) {
|
if err != nil {
|
||||||
return
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
log.WithError(err).Debug("failed to read local management asset hash")
|
||||||
}
|
}
|
||||||
return
|
localHash = ""
|
||||||
}
|
}
|
||||||
log.WithError(err).Warn("failed to download management asset")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
asset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL)
|
||||||
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
|
if err != nil {
|
||||||
}
|
if localFileMissing {
|
||||||
|
log.WithError(err).Warn("failed to fetch latest management release information, trying fallback page")
|
||||||
|
if ensureFallbackManagementHTML(ctx, client, localPath) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
log.WithError(err).Warn("failed to fetch latest management release information")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
if err = atomicWriteFile(localPath, data); err != nil {
|
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
|
||||||
log.WithError(err).Warn("failed to update management asset on disk")
|
log.Debug("management asset is already up to date")
|
||||||
return
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("management asset updated successfully (hash=%s)", downloadedHash)
|
data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)
|
||||||
|
if err != nil {
|
||||||
|
if localFileMissing {
|
||||||
|
log.WithError(err).Warn("failed to download management asset, trying fallback page")
|
||||||
|
if ensureFallbackManagementHTML(ctx, client, localPath) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
log.WithError(err).Warn("failed to download management asset")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
||||||
|
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = atomicWriteFile(localPath, data); err != nil {
|
||||||
|
log.WithError(err).Warn("failed to update management asset on disk")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("management asset updated successfully (hash=%s)", downloadedHash)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := os.Stat(localPath)
|
||||||
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, localPath string) bool {
|
func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, localPath string) bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user