package services import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "strings" "sync" "time" "github.com/hhftechnology/middleware-manager/models" "golang.org/x/sync/singleflight" ) // PluginFetcher fetches plugin information from Traefik API type PluginFetcher struct { config models.DataSourceConfig httpClient *http.Client singleflight singleflight.Group lastFetch time.Time lastFetchMu sync.RWMutex minInterval time.Duration // Cached data cachedPlugins []models.PluginResponse cachedPluginsMu sync.RWMutex } // NewPluginFetcher creates a new plugin fetcher func NewPluginFetcher(config models.DataSourceConfig) *PluginFetcher { httpClient := GetHTTPClient() return &PluginFetcher{ config: config, httpClient: httpClient, minInterval: 5 * time.Second, } } // FetchPlugins fetches all plugin information from Traefik API func (f *PluginFetcher) FetchPlugins(ctx context.Context) ([]models.PluginResponse, error) { result, err, _ := f.singleflight.Do("fetch-plugins", func() (interface{}, error) { return f.fetchPluginsInternal(ctx) }) if err != nil { return nil, err } return result.([]models.PluginResponse), nil } // fetchPluginsInternal performs the actual fetch func (f *PluginFetcher) fetchPluginsInternal(ctx context.Context) ([]models.PluginResponse, error) { // Check rate limiting f.lastFetchMu.RLock() timeSinceLastFetch := time.Since(f.lastFetch) f.lastFetchMu.RUnlock() if timeSinceLastFetch < f.minInterval { f.cachedPluginsMu.RLock() cached := f.cachedPlugins f.cachedPluginsMu.RUnlock() if cached != nil { return cached, nil } } log.Println("Fetching plugins from Traefik API...") // Get middlewares to find plugin-based middlewares middlewares, err := f.fetchMiddlewares(ctx) if err != nil { log.Printf("Warning: Failed to fetch middlewares for plugin detection: %v", err) middlewares = []models.TraefikMiddleware{} } // Get overview for plugin status overview, err := f.fetchOverview(ctx) if err != nil { log.Printf("Warning: Failed to fetch overview for plugin status: %v", err) } // Build plugin responses from middleware data plugins := f.buildPluginResponses(middlewares, overview) // Update cache f.cachedPluginsMu.Lock() f.cachedPlugins = plugins f.cachedPluginsMu.Unlock() f.lastFetchMu.Lock() f.lastFetch = time.Now() f.lastFetchMu.Unlock() log.Printf("Fetched %d plugins from Traefik API", len(plugins)) return plugins, nil } // fetchMiddlewares fetches all middlewares from Traefik API func (f *PluginFetcher) fetchMiddlewares(ctx context.Context) ([]models.TraefikMiddleware, error) { url := f.config.URL + "/api/http/middlewares" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } if f.config.BasicAuth.Username != "" { req.SetBasicAuth(f.config.BasicAuth.Username, f.config.BasicAuth.Password) } resp, err := f.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Try to decode as array or map return DecodeArrayOrMap[models.TraefikMiddleware](body, func(m *models.TraefikMiddleware, name string) { m.Name = name }) } // fetchOverview fetches Traefik overview for plugin status func (f *PluginFetcher) fetchOverview(ctx context.Context) (*models.TraefikPluginOverview, error) { url := f.config.URL + "/api/overview" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } if f.config.BasicAuth.Username != "" { req.SetBasicAuth(f.config.BasicAuth.Username, f.config.BasicAuth.Password) } resp, err := f.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("HTTP request failed: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024)) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } var overview models.TraefikPluginOverview if err := json.Unmarshal(body, &overview); err != nil { return nil, fmt.Errorf("failed to parse overview: %w", err) } return &overview, nil } // buildPluginResponses builds plugin responses from middleware and overview data func (f *PluginFetcher) buildPluginResponses(middlewares []models.TraefikMiddleware, overview *models.TraefikPluginOverview) []models.PluginResponse { // Map to track unique plugins and their usage pluginMap := make(map[string]*models.PluginResponse) // Extract plugins from middlewares - if a middleware references a plugin, it's running for _, mw := range middlewares { // Check if this middleware is a plugin type if mw.Type != "plugin" && !isPluginMiddleware(mw) { continue } pluginName := extractPluginName(mw) if pluginName == "" { continue } // Get or create plugin entry plugin, exists := pluginMap[pluginName] if !exists { plugin = &models.PluginResponse{ Name: pluginName, ModuleName: extractModuleName(mw, pluginName), Version: extractVersion(mw), Type: "middleware", Provider: mw.Provider, Status: "enabled", // If middleware uses plugin, plugin IS enabled IsInstalled: true, UsageCount: 0, UsedBy: []string{}, Config: mw.Config, } pluginMap[pluginName] = plugin } // Track usage plugin.UsageCount++ plugin.UsedBy = append(plugin.UsedBy, mw.Name) } // Also scan middlewares for plugin provider patterns (e.g., "plugin-badger@file") // These indicate the plugin is loaded and registered for _, mw := range middlewares { // Check if middleware name indicates it's a plugin middleware if strings.Contains(mw.Name, "plugin-") || strings.Contains(mw.Provider, "plugin") { // Extract plugin name from middleware name pluginName := extractPluginNameFromMiddlewareName(mw.Name) if pluginName != "" && pluginMap[pluginName] == nil { pluginMap[pluginName] = &models.PluginResponse{ Name: pluginName, ModuleName: pluginName, Type: "middleware", Provider: mw.Provider, Status: "enabled", IsInstalled: true, UsageCount: 1, UsedBy: []string{mw.Name}, } } } } // Update status from overview if available if overview != nil { for _, name := range overview.Plugins.Enabled { if plugin, ok := pluginMap[name]; ok { plugin.Status = "enabled" } else { // Plugin is enabled but not used in any middleware pluginMap[name] = &models.PluginResponse{ Name: name, ModuleName: name, Type: "middleware", Status: "enabled", IsInstalled: true, UsageCount: 0, UsedBy: []string{}, } } } for _, name := range overview.Plugins.Disabled { if plugin, ok := pluginMap[name]; ok { plugin.Status = "disabled" } else { pluginMap[name] = &models.PluginResponse{ Name: name, ModuleName: name, Type: "middleware", Status: "disabled", IsInstalled: true, UsageCount: 0, UsedBy: []string{}, } } } for _, name := range overview.Plugins.WithErrors { if plugin, ok := pluginMap[name]; ok { plugin.Status = "error" } else { pluginMap[name] = &models.PluginResponse{ Name: name, ModuleName: name, Type: "middleware", Status: "error", IsInstalled: true, UsageCount: 0, UsedBy: []string{}, } } } } // Convert map to slice plugins := make([]models.PluginResponse, 0, len(pluginMap)) for _, plugin := range pluginMap { plugins = append(plugins, *plugin) } return plugins } // extractPluginNameFromMiddlewareName extracts plugin name from middleware naming patterns func extractPluginNameFromMiddlewareName(name string) string { // Handle patterns like "plugin-badger@file" or "badger-middleware@file" name = strings.TrimPrefix(name, "plugin-") if idx := strings.Index(name, "@"); idx != -1 { name = name[:idx] } name = strings.TrimSuffix(name, "-middleware") return name } // isPluginMiddleware checks if a middleware is plugin-based func isPluginMiddleware(mw models.TraefikMiddleware) bool { // Check if config contains a "plugin" key if mw.Config != nil { if _, ok := mw.Config["plugin"]; ok { return true } } // Check if type indicates plugin if strings.Contains(strings.ToLower(mw.Type), "plugin") { return true } return false } // extractPluginName extracts the plugin name from a middleware func extractPluginName(mw models.TraefikMiddleware) string { if mw.Config != nil { // Check for plugin config structure: {"plugin": {"pluginName": {...}}} if pluginConfig, ok := mw.Config["plugin"].(map[string]interface{}); ok { for name := range pluginConfig { return name } } } // Try to extract from middleware name (e.g., "badger@file" -> "badger") name := mw.Name if idx := strings.Index(name, "@"); idx != -1 { name = name[:idx] } // Remove common suffixes name = strings.TrimSuffix(name, "-middleware") name = strings.TrimSuffix(name, "-plugin") return name } // extractModuleName extracts the module name from middleware config func extractModuleName(mw models.TraefikMiddleware, fallback string) string { if mw.Config != nil { if pluginConfig, ok := mw.Config["plugin"].(map[string]interface{}); ok { for _, config := range pluginConfig { if configMap, ok := config.(map[string]interface{}); ok { if moduleName, ok := configMap["moduleName"].(string); ok { return moduleName } } } } } return fallback } // extractVersion extracts version from middleware config func extractVersion(mw models.TraefikMiddleware) string { if mw.Config != nil { if pluginConfig, ok := mw.Config["plugin"].(map[string]interface{}); ok { for _, config := range pluginConfig { if configMap, ok := config.(map[string]interface{}); ok { if version, ok := configMap["version"].(string); ok { return version } } } } } return "" } // GetCachedPlugins returns cached plugins without fetching func (f *PluginFetcher) GetCachedPlugins() []models.PluginResponse { f.cachedPluginsMu.RLock() defer f.cachedPluginsMu.RUnlock() return f.cachedPlugins } // InvalidateCache clears the cached plugins func (f *PluginFetcher) InvalidateCache() { f.cachedPluginsMu.Lock() f.cachedPlugins = nil f.cachedPluginsMu.Unlock() } // CataloguePlugin represents a plugin from the Traefik plugin catalogue type CataloguePlugin struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"displayName"` Author string `json:"author"` Type string `json:"type"` Import string `json:"import"` Summary string `json:"summary"` IconURL string `json:"iconUrl,omitempty"` BannerURL string `json:"bannerUrl,omitempty"` Readme string `json:"readme,omitempty"` LatestVersion string `json:"latestVersion"` Versions []string `json:"versions,omitempty"` Stars int `json:"stars"` Snippet struct { YAML string `json:"yaml,omitempty"` TOML string `json:"toml,omitempty"` Kubernetes string `json:"kubernetes,omitempty"` } `json:"snippet,omitempty"` CreatedAt string `json:"createdAt,omitempty"` } // CatalogueResponse represents the response from plugins.traefik.io type CatalogueResponse struct { Plugins []CataloguePlugin `json:"plugins"` } // FetchPluginCatalogue fetches the full plugin catalogue from plugins.traefik.io func FetchPluginCatalogue(ctx context.Context) ([]CataloguePlugin, error) { client := GetHTTPClient() // Use the direct API endpoint to get all plugins req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://plugins.traefik.io/api/services/plugins", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", "MiddlewareManager/1.0") req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch plugin catalogue: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("plugin catalogue returned status %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 50*1024*1024)) // 50MB limit if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } var allPlugins []CataloguePlugin if err := json.Unmarshal(body, &allPlugins); err != nil { return nil, fmt.Errorf("failed to parse plugin data: %w", err) } // Filter to only include middleware plugins var middlewarePlugins []CataloguePlugin for _, p := range allPlugins { if p.Type == "middleware" { middlewarePlugins = append(middlewarePlugins, p) } } log.Printf("Fetched %d middleware plugins from Traefik plugin catalogue (total: %d)", len(middlewarePlugins), len(allPlugins)) return middlewarePlugins, nil } // parseNextDataPlugins extracts plugin data from Next.js __NEXT_DATA__ script tag func parseNextDataPlugins(html string) ([]CataloguePlugin, error) { // Find the __NEXT_DATA__ script tag startMarker := `` startIdx := strings.Index(html, startMarker) if startIdx == -1 { return nil, fmt.Errorf("__NEXT_DATA__ script not found") } startIdx += len(startMarker) endIdx := strings.Index(html[startIdx:], endMarker) if endIdx == -1 { return nil, fmt.Errorf("__NEXT_DATA__ script end not found") } jsonData := html[startIdx : startIdx+endIdx] // Parse the JSON structure var nextData struct { Props struct { PageProps struct { Plugins []CataloguePlugin `json:"plugins"` } `json:"pageProps"` } `json:"props"` } if err := json.Unmarshal([]byte(jsonData), &nextData); err != nil { return nil, fmt.Errorf("failed to parse JSON: %w", err) } return nextData.Props.PageProps.Plugins, nil }