diff --git a/internal/api/server.go b/internal/api/server.go index 2ca0591a..651441e6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -683,14 +683,17 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) { if _, err := os.Stat(filePath); err != nil { if os.IsNotExist(err) { - go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) - c.AbortWithStatus(http.StatusNotFound) + // Synchronously ensure management.html is available with a detached context. + // 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 } - - log.WithError(err).Error("failed to stat management control panel asset") - c.AbortWithStatus(http.StatusInternalServerError) - return } c.File(filePath) diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index 2fbaab12..7284b729 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -21,6 +21,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" log "github.com/sirupsen/logrus" + "golang.org/x/sync/singleflight" ) const ( @@ -41,6 +42,7 @@ var ( currentConfigPtr atomic.Pointer[config.Config] schedulerOnce sync.Once schedulerConfigPath atomic.Value + sfGroup singleflight.Group ) // 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. -// The function is designed to run in a background goroutine and will never panic. -func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) { +// 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) bool { if ctx == nil { ctx = context.Background() } @@ -180,91 +182,97 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL staticDir = strings.TrimSpace(staticDir) if staticDir == "" { 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) - localFileMissing := false - if _, errStat := os.Stat(localPath); errStat != nil { - if errors.Is(errStat, os.ErrNotExist) { - localFileMissing = true - } else { - log.WithError(errStat).Debug("failed to stat local management asset") + + _, _, _ = sfGroup.Do(localPath, func() (interface{}, error) { + 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 nil, nil } - } + lastUpdateCheckTime = now + lastUpdateCheckMu.Unlock() - if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil { - log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset") - return - } - - releaseURL := resolveReleaseURL(panelRepository) - 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 + localFileMissing := false + if _, errStat := os.Stat(localPath); errStat != nil { + if errors.Is(errStat, os.ErrNotExist) { + localFileMissing = true + } else { + log.WithError(errStat).Debug("failed to stat local management asset") } - return } - log.WithError(err).Warn("failed to fetch latest management release information") - return - } - if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) { - log.Debug("management asset is already up to date") - return - } + if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil { + log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset") + return nil, nil + } - 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 + releaseURL := resolveReleaseURL(panelRepository) + 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") } - return + localHash = "" } - log.WithError(err).Warn("failed to download management asset") - return - } - if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) { - log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash) - } + 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 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 { - log.WithError(err).Warn("failed to update management asset on disk") - return - } + if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) { + log.Debug("management asset is already up to date") + 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 { diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 77e8d160..30c37726 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -4,12 +4,16 @@ import ( "bufio" "bytes" "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "fmt" "io" "net/http" "strings" "time" + "github.com/google/uuid" iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" @@ -453,6 +457,20 @@ func applyIFlowHeaders(r *http.Request, apiKey string, stream bool) { r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Bearer "+apiKey) r.Header.Set("User-Agent", iflowUserAgent) + + // Generate session-id + sessionID := "session-" + generateUUID() + r.Header.Set("session-id", sessionID) + + // Generate timestamp and signature + timestamp := time.Now().UnixMilli() + r.Header.Set("x-iflow-timestamp", fmt.Sprintf("%d", timestamp)) + + signature := createIFlowSignature(iflowUserAgent, sessionID, timestamp, apiKey) + if signature != "" { + r.Header.Set("x-iflow-signature", signature) + } + if stream { r.Header.Set("Accept", "text/event-stream") } else { @@ -460,6 +478,23 @@ func applyIFlowHeaders(r *http.Request, apiKey string, stream bool) { } } +// createIFlowSignature generates HMAC-SHA256 signature for iFlow API requests. +// The signature payload format is: userAgent:sessionId:timestamp +func createIFlowSignature(userAgent, sessionID string, timestamp int64, apiKey string) string { + if apiKey == "" { + return "" + } + payload := fmt.Sprintf("%s:%s:%d", userAgent, sessionID, timestamp) + h := hmac.New(sha256.New, []byte(apiKey)) + h.Write([]byte(payload)) + return hex.EncodeToString(h.Sum(nil)) +} + +// generateUUID generates a random UUID v4 string. +func generateUUID() string { + return uuid.New().String() +} + func iflowCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { if a == nil { return "", ""