diff --git a/.gitignore b/.gitignore index c9ae73b8..3a17d35e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ CLAUDE.md GEMINI.md *.exe temp/* -cli-proxy-api \ No newline at end of file +cli-proxy-api +static/* \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 3e0b4cf2..6cb4e42c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,6 +4,7 @@ package main import ( + "context" "flag" "fmt" "os" @@ -13,6 +14,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator" "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" @@ -114,6 +116,13 @@ func main() { } usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled) + staticDir := managementasset.StaticDir(configFilePath) + if !cfg.RemoteManagement.DisableControlPanel { + go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir) + } else { + log.Debug("management control panel disabled; skip asset sync") + } + if err = logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil { log.Fatalf("failed to configure log output: %v", err) } diff --git a/internal/api/server.go b/internal/api/server.go index 09ca7c7c..bc782bc6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -21,6 +21,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset" "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" @@ -225,6 +226,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk // setupRoutes configures the API routes for the server. // It defines the endpoints and associates them with their respective handlers. func (s *Server) setupRoutes() { + s.engine.GET("/management.html", s.serveManagementControlPanel) openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers) geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers) geminiCLIHandlers := gemini.NewGeminiCLIAPIHandler(s.handlers) @@ -388,6 +390,34 @@ func (s *Server) setupRoutes() { } } +func (s *Server) serveManagementControlPanel(c *gin.Context) { + cfg := s.cfg + if cfg == nil || cfg.RemoteManagement.DisableControlPanel { + c.AbortWithStatus(http.StatusNotFound) + return + } + + filePath := managementasset.FilePath(s.configFilePath) + if strings.TrimSpace(filePath) == "" { + c.AbortWithStatus(http.StatusNotFound) + return + } + + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath)) + c.AbortWithStatus(http.StatusNotFound) + return + } + + log.WithError(err).Error("failed to stat management control panel asset") + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + c.File(filePath) +} + func (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) { if timeout <= 0 || onTimeout == nil { return @@ -614,6 +644,11 @@ func (s *Server) UpdateClients(cfg *config.Config) { s.applyAccessConfig(oldCfg, cfg) s.cfg = cfg s.handlers.UpdateClients(&cfg.SDKConfig) + + if !cfg.RemoteManagement.DisableControlPanel { + staticDir := managementasset.StaticDir(s.configFilePath) + go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir) + } if s.mgmt != nil { s.mgmt.SetConfig(cfg) s.mgmt.SetAuthManager(s.handlers.AuthManager) diff --git a/internal/config/config.go b/internal/config/config.go index b9a34754..7f8a5c0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,6 +86,8 @@ type RemoteManagement struct { AllowRemote bool `yaml:"allow-remote"` // SecretKey is the management key (plaintext or bcrypt hashed). YAML key intentionally 'secret-key'. SecretKey string `yaml:"secret-key"` + // DisableControlPanel skips serving and syncing the bundled management UI when true. + DisableControlPanel bool `yaml:"disable-control-panel"` } // QuotaExceeded defines the behavior when API quota limits are exceeded. diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go new file mode 100644 index 00000000..5a34bc19 --- /dev/null +++ b/internal/managementasset/updater.go @@ -0,0 +1,245 @@ +package managementasset + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + managementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest" + managementAssetName = "management.html" + httpUserAgent = "CLIProxyAPI-management-updater" +) + +// ManagementFileName exposes the control panel asset filename. +const ManagementFileName = managementAssetName + +var httpClient = &http.Client{Timeout: 15 * time.Second} + +type releaseAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Digest string `json:"digest"` +} + +type releaseResponse struct { + Assets []releaseAsset `json:"assets"` +} + +// StaticDir resolves the directory that stores the management control panel asset. +func StaticDir(configFilePath string) string { + configFilePath = strings.TrimSpace(configFilePath) + if configFilePath == "" { + return "" + } + base := filepath.Dir(configFilePath) + return filepath.Join(base, "static") +} + +// FilePath resolves the absolute path to the management control panel asset. +func FilePath(configFilePath string) string { + dir := StaticDir(configFilePath) + if dir == "" { + return "" + } + return filepath.Join(dir, ManagementFileName) +} + +// 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) { + if ctx == nil { + ctx = context.Background() + } + + staticDir = strings.TrimSpace(staticDir) + if staticDir == "" { + log.Debug("management asset sync skipped: empty static directory") + return + } + + if err := os.MkdirAll(staticDir, 0o755); err != nil { + log.WithError(err).Warn("failed to prepare static directory for management asset") + return + } + + localPath := filepath.Join(staticDir, managementAssetName) + 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) + if err != nil { + 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 + } + + data, downloadedHash, err := downloadAsset(ctx, asset.BrowserDownloadURL) + if err != nil { + 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) + } + + if err = atomicWriteFile(localPath, data); err != nil { + log.WithError(err).Warn("failed to update management asset on disk") + return + } + + log.Infof("management asset updated successfully (hash=%s)", downloadedHash) +} + +func fetchLatestAsset(ctx context.Context) (*releaseAsset, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, managementReleaseURL, nil) + if err != nil { + return nil, "", fmt.Errorf("create release request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", httpUserAgent) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("execute release request: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, "", fmt.Errorf("unexpected release status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var release releaseResponse + if err = json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, "", fmt.Errorf("decode release response: %w", err) + } + + for i := range release.Assets { + asset := &release.Assets[i] + if strings.EqualFold(asset.Name, managementAssetName) { + remoteHash := parseDigest(asset.Digest) + return asset, remoteHash, nil + } + } + + return nil, "", fmt.Errorf("management asset %s not found in latest release", managementAssetName) +} + +func downloadAsset(ctx context.Context, downloadURL string) ([]byte, string, error) { + if strings.TrimSpace(downloadURL) == "" { + return nil, "", fmt.Errorf("empty download url") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return nil, "", fmt.Errorf("create download request: %w", err) + } + req.Header.Set("User-Agent", httpUserAgent) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("execute download request: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("read download body: %w", err) + } + + sum := sha256.Sum256(data) + return data, hex.EncodeToString(sum[:]), nil +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { + _ = file.Close() + }() + + h := sha256.New() + if _, err = io.Copy(h, file); err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +func atomicWriteFile(path string, data []byte) error { + tmpFile, err := os.CreateTemp(filepath.Dir(path), "management-*.html") + if err != nil { + return err + } + + tmpName := tmpFile.Name() + defer func() { + _ = tmpFile.Close() + _ = os.Remove(tmpName) + }() + + if _, err = tmpFile.Write(data); err != nil { + return err + } + + if err = tmpFile.Chmod(0o644); err != nil { + return err + } + + if err = tmpFile.Close(); err != nil { + return err + } + + if err = os.Rename(tmpName, path); err != nil { + return err + } + + return nil +} + +func parseDigest(digest string) string { + digest = strings.TrimSpace(digest) + if digest == "" { + return "" + } + + if idx := strings.Index(digest, ":"); idx >= 0 { + digest = digest[idx+1:] + } + + return strings.ToLower(strings.TrimSpace(digest)) +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index cf04e2bd..fe701b58 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -532,6 +532,9 @@ func (w *Watcher) reloadConfig() bool { if oldConfig.RemoteManagement.AllowRemote != newConfig.RemoteManagement.AllowRemote { log.Debugf(" remote-management.allow-remote: %t -> %t", oldConfig.RemoteManagement.AllowRemote, newConfig.RemoteManagement.AllowRemote) } + if oldConfig.RemoteManagement.DisableControlPanel != newConfig.RemoteManagement.DisableControlPanel { + log.Debugf(" remote-management.disable-control-panel: %t -> %t", oldConfig.RemoteManagement.DisableControlPanel, newConfig.RemoteManagement.DisableControlPanel) + } if oldConfig.LoggingToFile != newConfig.LoggingToFile { log.Debugf(" logging-to-file: %t -> %t", oldConfig.LoggingToFile, newConfig.LoggingToFile) }