package api import ( "context" "errors" "io/fs" "net/http" "path" "regexp" "strings" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset" log "github.com/sirupsen/logrus" ) var hashedManagementAssetPattern = regexp.MustCompile(`(^|[-.])[A-Za-z0-9_-]{8,}(\.[^.]+)+$`) func (s *Server) registerManagementRoutes() { if s == nil || s.engine == nil || s.mgmt == nil { return } if !s.managementRoutesRegistered.CompareAndSwap(false, true) { return } log.Info("management routes registered after secret key configuration") s.engine.POST("/v0/management/oauth-callback", s.managementAvailabilityMiddleware(), s.mgmt.PostOAuthCallback) s.engine.GET("/v0/management/oauth-callback", s.managementAvailabilityMiddleware(), s.mgmt.GetOAuthCallback) mgmt := s.engine.Group("/v0/management") mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware()) { mgmt.GET("/config", s.mgmt.GetConfig) mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML) mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML) mgmt.GET("/latest-version", s.mgmt.GetLatestVersion) mgmt.GET("/plugins", s.mgmt.ListPlugins) mgmt.GET("/plugin-store", s.mgmt.ListPluginStore) mgmt.POST("/plugin-store/:id/install", s.mgmt.InstallPluginFromStore) mgmt.DELETE("/plugins/:id", s.mgmt.DeletePlugin) mgmt.PATCH("/plugins/:id/enabled", s.mgmt.PatchPluginEnabled) mgmt.GET("/plugins/:id/config", s.mgmt.GetPluginConfig) mgmt.PUT("/plugins/:id/config", s.mgmt.PutPluginConfig) mgmt.PATCH("/plugins/:id/config", s.mgmt.PatchPluginConfig) mgmt.GET("/debug", s.mgmt.GetDebug) mgmt.PUT("/debug", s.mgmt.PutDebug) mgmt.PATCH("/debug", s.mgmt.PutDebug) mgmt.GET("/logging-to-file", s.mgmt.GetLoggingToFile) mgmt.PUT("/logging-to-file", s.mgmt.PutLoggingToFile) mgmt.PATCH("/logging-to-file", s.mgmt.PutLoggingToFile) mgmt.GET("/logs-max-total-size-mb", s.mgmt.GetLogsMaxTotalSizeMB) mgmt.PUT("/logs-max-total-size-mb", s.mgmt.PutLogsMaxTotalSizeMB) mgmt.PATCH("/logs-max-total-size-mb", s.mgmt.PutLogsMaxTotalSizeMB) mgmt.GET("/error-logs-max-files", s.mgmt.GetErrorLogsMaxFiles) mgmt.PUT("/error-logs-max-files", s.mgmt.PutErrorLogsMaxFiles) mgmt.PATCH("/error-logs-max-files", s.mgmt.PutErrorLogsMaxFiles) mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled) mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled) mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled) mgmt.GET("/proxy-url", s.mgmt.GetProxyURL) mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL) mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL) mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL) mgmt.POST("/api-call", s.mgmt.APICall) mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject) mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject) mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject) mgmt.GET("/quota-exceeded/switch-preview-model", s.mgmt.GetSwitchPreviewModel) mgmt.PUT("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel) mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel) mgmt.POST("/reset-quota", s.mgmt.ResetQuota) mgmt.GET("/api-keys", s.mgmt.GetAPIKeys) mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys) mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys) mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys) mgmt.GET("/api-key-usage", s.mgmt.GetAPIKeyUsage) mgmt.GET("/usage-queue", s.mgmt.GetUsageQueue) mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys) mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys) mgmt.PATCH("/gemini-api-key", s.mgmt.PatchGeminiKey) mgmt.DELETE("/gemini-api-key", s.mgmt.DeleteGeminiKey) mgmt.GET("/interactions-api-key", s.mgmt.GetInteractionsKeys) mgmt.PUT("/interactions-api-key", s.mgmt.PutInteractionsKeys) mgmt.PATCH("/interactions-api-key", s.mgmt.PatchInteractionsKey) mgmt.DELETE("/interactions-api-key", s.mgmt.DeleteInteractionsKey) mgmt.GET("/logs", s.mgmt.GetLogs) mgmt.DELETE("/logs", s.mgmt.DeleteLogs) mgmt.GET("/request-error-logs", s.mgmt.GetRequestErrorLogs) mgmt.GET("/request-error-logs/:name", s.mgmt.DownloadRequestErrorLog) mgmt.GET("/request-log-by-id/:id", s.mgmt.GetRequestLogByID) mgmt.GET("/request-log", s.mgmt.GetRequestLog) mgmt.PUT("/request-log", s.mgmt.PutRequestLog) mgmt.PATCH("/request-log", s.mgmt.PutRequestLog) mgmt.GET("/ws-auth", s.mgmt.GetWebsocketAuth) mgmt.PUT("/ws-auth", s.mgmt.PutWebsocketAuth) mgmt.PATCH("/ws-auth", s.mgmt.PutWebsocketAuth) mgmt.GET("/request-retry", s.mgmt.GetRequestRetry) mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry) mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry) mgmt.GET("/max-retry-credentials", s.mgmt.GetMaxRetryCredentials) mgmt.PUT("/max-retry-credentials", s.mgmt.PutMaxRetryCredentials) mgmt.PATCH("/max-retry-credentials", s.mgmt.PutMaxRetryCredentials) mgmt.GET("/max-retry-interval", s.mgmt.GetMaxRetryInterval) mgmt.PUT("/max-retry-interval", s.mgmt.PutMaxRetryInterval) mgmt.PATCH("/max-retry-interval", s.mgmt.PutMaxRetryInterval) mgmt.GET("/force-model-prefix", s.mgmt.GetForceModelPrefix) mgmt.PUT("/force-model-prefix", s.mgmt.PutForceModelPrefix) mgmt.PATCH("/force-model-prefix", s.mgmt.PutForceModelPrefix) mgmt.GET("/routing/strategy", s.mgmt.GetRoutingStrategy) mgmt.PUT("/routing/strategy", s.mgmt.PutRoutingStrategy) mgmt.PATCH("/routing/strategy", s.mgmt.PutRoutingStrategy) mgmt.GET("/claude-api-key", s.mgmt.GetClaudeKeys) mgmt.PUT("/claude-api-key", s.mgmt.PutClaudeKeys) mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey) mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey) mgmt.GET("/codex-api-key", s.mgmt.GetCodexKeys) mgmt.PUT("/codex-api-key", s.mgmt.PutCodexKeys) mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey) mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey) mgmt.GET("/xai-api-key", s.mgmt.GetXAIKeys) mgmt.PUT("/xai-api-key", s.mgmt.PutXAIKeys) mgmt.PATCH("/xai-api-key", s.mgmt.PatchXAIKey) mgmt.DELETE("/xai-api-key", s.mgmt.DeleteXAIKey) mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat) mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat) mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat) mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat) mgmt.GET("/vertex-api-key", s.mgmt.GetVertexCompatKeys) mgmt.PUT("/vertex-api-key", s.mgmt.PutVertexCompatKeys) mgmt.PATCH("/vertex-api-key", s.mgmt.PatchVertexCompatKey) mgmt.DELETE("/vertex-api-key", s.mgmt.DeleteVertexCompatKey) mgmt.GET("/oauth-excluded-models", s.mgmt.GetOAuthExcludedModels) mgmt.PUT("/oauth-excluded-models", s.mgmt.PutOAuthExcludedModels) mgmt.PATCH("/oauth-excluded-models", s.mgmt.PatchOAuthExcludedModels) mgmt.DELETE("/oauth-excluded-models", s.mgmt.DeleteOAuthExcludedModels) mgmt.GET("/oauth-model-alias", s.mgmt.GetOAuthModelAlias) mgmt.PUT("/oauth-model-alias", s.mgmt.PutOAuthModelAlias) mgmt.PATCH("/oauth-model-alias", s.mgmt.PatchOAuthModelAlias) mgmt.DELETE("/oauth-model-alias", s.mgmt.DeleteOAuthModelAlias) mgmt.GET("/oauth-request-scoped-errors", s.mgmt.GetOAuthRequestScopedErrors) mgmt.PUT("/oauth-request-scoped-errors", s.mgmt.PutOAuthRequestScopedErrors) mgmt.PATCH("/oauth-request-scoped-errors", s.mgmt.PatchOAuthRequestScopedErrors) mgmt.DELETE("/oauth-request-scoped-errors", s.mgmt.DeleteOAuthRequestScopedErrors) mgmt.GET("/auth-files", s.mgmt.ListAuthFiles) mgmt.GET("/auth-files/models", s.mgmt.GetAuthFileModels) mgmt.GET("/model-definitions/:channel", s.mgmt.GetStaticModelDefinitions) mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile) mgmt.POST("/auth-files", s.mgmt.UploadAuthFile) mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile) mgmt.PATCH("/auth-files/status", s.mgmt.PatchAuthFileStatus) mgmt.PATCH("/auth-files/fields", s.mgmt.PatchAuthFileFields) mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential) mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken) mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken) mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken) mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken) mgmt.GET("/xai-auth-url", s.mgmt.RequestXAIToken) mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) mgmt.DELETE("/oauth-session", s.mgmt.CancelAuthSession) } } func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if !s.managementAvailable(c) { return } c.Next() } } func (s *Server) managementAvailable(c *gin.Context) bool { if s == nil || s.cfg == nil { c.AbortWithStatus(http.StatusNotFound) return false } if s.cfg.Home.Enabled { c.AbortWithStatus(http.StatusNotFound) return false } if !s.managementRoutesEnabled.Load() { c.AbortWithStatus(http.StatusNotFound) return false } return true } func (s *Server) refreshPluginManagementRoutes() { if s == nil || s.pluginHost == nil || s.engine == nil { return } s.pluginHost.RegisterManagementRoutes(context.Background(), s.registeredManagementRouteKeys()) } // RefreshPluginManagementRoutes rebuilds plugin-owned Management API routes. func (s *Server) RefreshPluginManagementRoutes() { s.refreshPluginManagementRoutes() } func (s *Server) registeredManagementRouteKeys() map[string]struct{} { out := make(map[string]struct{}) if s == nil || s.engine == nil { return out } for _, route := range s.engine.Routes() { if strings.HasPrefix(route.Path, "/v0/management/") || route.Path == "/v0/management" { out[strings.ToUpper(strings.TrimSpace(route.Method))+" "+route.Path] = struct{}{} } } return out } func (s *Server) pluginManagementNoRoute(c *gin.Context) { if s == nil || c == nil || c.Request == nil || c.Request.URL == nil { if c != nil { c.AbortWithStatus(http.StatusNotFound) } return } path := c.Request.URL.Path if strings.HasPrefix(path, "/v0/resource/plugins/") { s.pluginResourceNoRoute(c) return } if path != "/v0/management" && !strings.HasPrefix(path, "/v0/management/") { c.AbortWithStatus(http.StatusNotFound) return } if s.pluginHost == nil || s.mgmt == nil { c.AbortWithStatus(http.StatusNotFound) return } if !s.managementAvailable(c) { return } s.mgmt.Middleware()(c) if c.IsAborted() { return } if s.mgmt.ServePluginAuthURL(c) { c.Abort() return } if s.pluginHost.ServeManagementHTTP(c.Writer, c.Request) { c.Abort() return } c.AbortWithStatus(http.StatusNotFound) } func (s *Server) pluginResourceNoRoute(c *gin.Context) { if s == nil || c == nil || c.Request == nil || c.Request.URL == nil { if c != nil { c.AbortWithStatus(http.StatusNotFound) } return } if s.cfg == nil || s.cfg.Home.Enabled || s.pluginHost == nil { c.AbortWithStatus(http.StatusNotFound) return } if s.pluginHost.ServeResourceHTTP(c.Writer, c.Request) { c.Abort() return } c.AbortWithStatus(http.StatusNotFound) } func (s *Server) serveManagementControlPanel(c *gin.Context) { cfg := s.cfg if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel { c.AbortWithStatus(http.StatusNotFound) return } source, err := managementasset.Current() if err != nil { if !errors.Is(err, fs.ErrNotExist) { log.WithError(err).Error("failed to resolve management control panel assets") } c.AbortWithStatus(http.StatusNotFound) return } s.serveManagementFile(c, source, source.Entry, false) } func (s *Server) serveManagementAsset(c *gin.Context) { cfg := s.cfg if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel { c.AbortWithStatus(http.StatusNotFound) return } assetPath := strings.TrimPrefix(c.Param("filepath"), "/") if assetPath == "" || strings.Contains(assetPath, `\`) || !fs.ValidPath(assetPath) { c.AbortWithStatus(http.StatusNotFound) return } source, err := managementasset.Current() if err != nil { if !errors.Is(err, fs.ErrNotExist) { log.WithError(err).Error("failed to resolve management control panel assets") } c.AbortWithStatus(http.StatusNotFound) return } s.serveManagementFile(c, source, assetPath, true) } func (s *Server) serveManagementFile(c *gin.Context, source *managementasset.Source, name string, cacheHashed bool) { file, err := source.FileSystem.Open(name) if err != nil { c.AbortWithStatus(http.StatusNotFound) return } defer func() { if errClose := file.Close(); errClose != nil { log.WithError(errClose).Debug("failed to close management asset") } }() info, err := file.Stat() if err != nil || info.IsDir() { c.AbortWithStatus(http.StatusNotFound) return } if !cacheHashed { c.Header("Content-Type", "text/html; charset=utf-8") } else if hashedManagementAssetPattern.MatchString(path.Base(name)) { c.Header("Cache-Control", "public, max-age=31536000, immutable") } http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file) }