middleware-manager/services/config_proxy.go
hhftechnologies 90a75b5a93 harden HTTP usage, caching, and transactional deletes
add safety across services: whitelist tables for DeleteInTransaction to prevent dynamic-SQL deletion abuse; remove a deprecated UpdateInTransaction helper. centralize HTTP client creation (HTTPClientWithTimeout/GetHTTPClient) and replace ad-hoc http.Clients with it; limit response body reads with io.LimitReader to avoid unbounded memory use; add better error logging when JSON marshal fails. Improve ConfigProxy cache handling to fetch outside locks, return stale cache on fetch errors, and only lock to swap the cache; add locking to SetCacheDuration and cap error body reads. convert ResourceWatcher isRunning to atomic.Bool for safe concurrent start/stop. Replace sync.Map memoization in id_normalizer with a bounded map protected by RWMutex (maxCacheSize), add cache flush behavior and tests/benchmarks to validate boundedness and hits. Miscellaneous test updates to match new behavior.
2026-03-01 14:22:02 +05:30

1437 lines
43 KiB
Go

package services
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/hhftechnology/middleware-manager/database"
"github.com/hhftechnology/middleware-manager/models"
)
// ProxiedTraefikConfig represents the full Traefik config structure (JSON format)
type ProxiedTraefikConfig struct {
HTTP *HTTPConfig `json:"http,omitempty"`
TCP *TCPConfig `json:"tcp,omitempty"`
UDP *UDPConfig `json:"udp,omitempty"`
TLS *TLSConfig `json:"tls,omitempty"`
}
// HTTPConfig represents HTTP configuration section
type HTTPConfig struct {
Middlewares map[string]interface{} `json:"middlewares,omitempty"`
Routers map[string]interface{} `json:"routers,omitempty"`
Services map[string]interface{} `json:"services,omitempty"`
}
// TCPConfig represents TCP configuration section
type TCPConfig struct {
Routers map[string]interface{} `json:"routers,omitempty"`
Services map[string]interface{} `json:"services,omitempty"`
}
// UDPConfig represents UDP configuration section
type UDPConfig struct {
Routers map[string]interface{} `json:"routers,omitempty"`
Services map[string]interface{} `json:"services,omitempty"`
}
// TLSConfig represents TLS configuration section
type TLSConfig struct {
Options map[string]interface{} `json:"options,omitempty"`
}
// OrderedRouter represents a Traefik HTTP router with fields in Pangolin's order.
// The JSON field order matches Pangolin API output for consistency.
type OrderedRouter struct {
EntryPoints []string `json:"entryPoints,omitempty"`
Middlewares []string `json:"middlewares,omitempty"`
Service string `json:"service,omitempty"`
Rule string `json:"rule,omitempty"`
Priority int `json:"priority,omitempty"`
TLS *OrderedTLSConfig `json:"tls,omitempty"`
}
// OrderedTLSConfig represents TLS config for a router with Pangolin's field order.
type OrderedTLSConfig struct {
CertResolver string `json:"certResolver,omitempty"`
Domains []string `json:"domains,omitempty"`
Options string `json:"options,omitempty"`
}
// OrderedMiddleware represents a middleware with Pangolin's field order.
type OrderedMiddleware struct {
RedirectScheme map[string]interface{} `json:"redirectScheme,omitempty"`
Plugin map[string]interface{} `json:"plugin,omitempty"`
Headers map[string]interface{} `json:"headers,omitempty"`
}
type middlewareWithPriority struct {
ID string
Name string
Priority int
}
type externalMiddlewareRef struct {
Name string
Priority int
}
type mtlsConfigData struct {
CACertPath string
Rules []interface{}
RequestHeaders map[string]string
RejectMessage string
RejectCode int
RefreshInterval string
}
type resourceData struct {
ID string // Internal UUID (stable)
PangolinRouterID string // Pangolin's router ID (can change)
Host string
ServiceID string
Entrypoints string
TLSDomains string
CustomHeaders string
RouterPriority int
SourceType string
MTLSEnabled bool
MTLSRules sql.NullString
MTLSRequestHdrs sql.NullString
MTLSRejectMsg sql.NullString
MTLSRejectCode sql.NullInt64
MTLSRefresh sql.NullString
MTLSExternal sql.NullString
TLSHardeningEnabled bool
SecureHeadersEnabled bool
Middlewares []middlewareWithPriority
ExternalMiddlewares []externalMiddlewareRef
CustomServiceID sql.NullString
}
// securityConfigData holds global security settings from the database
type securityConfigData struct {
TLSHardeningEnabled bool
SecureHeadersEnabled bool
SecureHeaders models.SecureHeadersConfig
}
// ConfigProxy fetches config from Pangolin and merges MW-manager additions
type ConfigProxy struct {
db *database.DB
configManager *ConfigManager
pangolinURL string
httpClient *http.Client
// Caching
cache *ProxiedTraefikConfig
cacheExpiry time.Time
cacheDuration time.Duration
cacheMutex sync.RWMutex
}
// NewConfigProxy creates a new config proxy instance
func NewConfigProxy(db *database.DB, configManager *ConfigManager, pangolinURL string) *ConfigProxy {
return &ConfigProxy{
db: db,
configManager: configManager,
pangolinURL: pangolinURL,
httpClient: HTTPClientWithTimeout(10 * time.Second),
cacheDuration: 5 * time.Second, // Match typical Traefik poll interval
}
}
// GetMergedConfig returns the merged Pangolin + MW-manager configuration
func (cp *ConfigProxy) GetMergedConfig() (*ProxiedTraefikConfig, error) {
// Try to use cached config
cp.cacheMutex.RLock()
if cp.cache != nil && time.Now().Before(cp.cacheExpiry) {
defer cp.cacheMutex.RUnlock()
return cp.cache, nil
}
staleCache := cp.cache
cp.cacheMutex.RUnlock()
// Fetch fresh config OUTSIDE the lock to avoid blocking readers
config, err := cp.fetchPangolinConfig()
if err != nil {
// Return stale cache on error if available
if staleCache != nil {
log.Printf("Warning: Pangolin fetch failed, using stale cache: %v", err)
return staleCache, nil
}
return nil, fmt.Errorf("failed to fetch Pangolin config: %w", err)
}
// Merge MW-manager additions (no lock needed, operates on local config)
if err := cp.mergeMiddlewareManagerConfig(config); err != nil {
return nil, fmt.Errorf("failed to merge MW-manager config: %w", err)
}
// Remove empty protocol sections so Traefik doesn't reject blank configs
cp.pruneEmptySections(config)
// Normalize router field ordering to match Pangolin's JSON format
cp.normalizeRouterOrder(config)
// Normalize middleware field ordering to match Pangolin's JSON format
cp.normalizeMiddlewareOrder(config)
// Lock only to swap the cache
cp.cacheMutex.Lock()
cp.cache = config
cp.cacheExpiry = time.Now().Add(cp.cacheDuration)
cp.cacheMutex.Unlock()
return config, nil
}
// InvalidateCache forces the next GetMergedConfig call to fetch fresh data
func (cp *ConfigProxy) InvalidateCache() {
cp.cacheMutex.Lock()
defer cp.cacheMutex.Unlock()
cp.cacheExpiry = time.Now().Add(-1 * time.Second) // Expire immediately
}
// fetchPangolinConfig fetches the Traefik configuration from Pangolin API
func (cp *ConfigProxy) fetchPangolinConfig() (*ProxiedTraefikConfig, error) {
// Use configured Pangolin URL or get from config manager
pangolinURL := cp.pangolinURL
if pangolinURL == "" {
// Try to get from environment or config
pangolinURL = os.Getenv("PANGOLIN_URL")
if pangolinURL == "" {
pangolinURL = "http://pangolin:3001"
}
}
// Build the correct URL - handle both base URL and URLs that include /api/v1
pangolinURL = strings.TrimSuffix(pangolinURL, "/")
var url string
if strings.HasSuffix(pangolinURL, "/api/v1") {
// URL already includes /api/v1, just append traefik-config
url = pangolinURL + "/traefik-config"
} else {
// Base URL, append full path
url = pangolinURL + "/api/v1/traefik-config"
}
if shouldLogInfo() {
log.Printf("Fetching Pangolin config from: %s", url)
}
resp, err := cp.httpClient.Get(url)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024)) // 1MB limit for error body
return nil, fmt.Errorf("Pangolin returned status %d: %s", resp.StatusCode, string(body))
}
var config ProxiedTraefikConfig
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
return nil, fmt.Errorf("failed to decode Pangolin response: %w", err)
}
// Initialize nil maps
cp.initializeConfigMaps(&config)
return &config, nil
}
// initializeConfigMaps ensures all config maps are initialized
func (cp *ConfigProxy) initializeConfigMaps(config *ProxiedTraefikConfig) {
if config.HTTP == nil {
config.HTTP = &HTTPConfig{}
}
if config.HTTP.Middlewares == nil {
config.HTTP.Middlewares = make(map[string]interface{})
}
if config.HTTP.Routers == nil {
config.HTTP.Routers = make(map[string]interface{})
}
if config.HTTP.Services == nil {
config.HTTP.Services = make(map[string]interface{})
}
if config.TCP == nil {
config.TCP = &TCPConfig{}
}
if config.TCP.Routers == nil {
config.TCP.Routers = make(map[string]interface{})
}
if config.TCP.Services == nil {
config.TCP.Services = make(map[string]interface{})
}
if config.UDP == nil {
config.UDP = &UDPConfig{}
}
if config.UDP.Routers == nil {
config.UDP.Routers = make(map[string]interface{})
}
if config.UDP.Services == nil {
config.UDP.Services = make(map[string]interface{})
}
if config.TLS == nil {
config.TLS = &TLSConfig{}
}
if config.TLS.Options == nil {
config.TLS.Options = make(map[string]interface{})
}
}
// pruneEmptySections removes protocol blocks that contain no routers or services.
// Traefik rejects empty protocol sections from the HTTP provider response.
func (cp *ConfigProxy) pruneEmptySections(config *ProxiedTraefikConfig) {
if config == nil {
return
}
if config.TCP != nil {
if len(config.TCP.Routers) == 0 && len(config.TCP.Services) == 0 {
config.TCP = nil
}
}
if config.UDP != nil {
if len(config.UDP.Routers) == 0 && len(config.UDP.Services) == 0 {
config.UDP = nil
}
}
if config.TLS != nil {
if len(config.TLS.Options) == 0 {
config.TLS = nil
}
}
}
// mergeMiddlewareManagerConfig merges MW-manager middlewares into the config
// NOTE: Routers and services come from Pangolin API and are NOT modified here.
func (cp *ConfigProxy) mergeMiddlewareManagerConfig(config *ProxiedTraefikConfig) error {
// Load resources and their middleware assignments
resources, err := cp.fetchResourceData()
if err != nil {
return fmt.Errorf("failed to fetch resources: %w", err)
}
// Load global security config
securityCfg, err := cp.loadSecurityConfig()
if err != nil {
log.Printf("Warning: failed to load security config: %v", err)
securityCfg = nil
}
assignedMiddlewareIDs := make(map[string]struct{})
hasMTLSResources := false
hasTLSHardeningResources := false
for _, res := range resources {
if res.MTLSEnabled {
hasMTLSResources = true
}
if res.TLSHardeningEnabled && !res.MTLSEnabled {
hasTLSHardeningResources = true
}
for _, mw := range res.Middlewares {
assignedMiddlewareIDs[mw.ID] = struct{}{}
}
}
var mtlsCfg *mtlsConfigData
if hasMTLSResources {
cfg, err := cp.loadGlobalMTLSConfig()
if err != nil {
return fmt.Errorf("failed to load global mTLS config: %w", err)
}
mtlsCfg = cfg
if mtlsCfg != nil {
cp.applyTLSOptions(config, mtlsCfg)
}
}
// Apply TLS hardening options if any resource has it enabled (and not mTLS)
if hasTLSHardeningResources {
cp.applyTLSHardeningOptions(config)
}
// Only add MW-manager middlewares that are assigned to resources/routers
if len(assignedMiddlewareIDs) > 0 {
if err := cp.applyMiddlewares(config, assignedMiddlewareIDs); err != nil {
return fmt.Errorf("failed to apply middlewares: %w", err)
}
}
// Apply resource-specific overrides (middleware attachments, priorities, headers, mtls, security)
if len(resources) > 0 {
if err := cp.applyResourceOverrides(config, resources, mtlsCfg, securityCfg); err != nil {
return fmt.Errorf("failed to apply resource overrides: %w", err)
}
}
// Sanitize mtlswhitelist requestHeaders to ensure map type (Traefik plugin is strict)
cp.sanitizeMTLSWhitelist(config)
return nil
}
// applyMiddlewares adds custom middlewares from the database
func (cp *ConfigProxy) applyMiddlewares(config *ProxiedTraefikConfig, allowedIDs map[string]struct{}) error {
rows, err := cp.db.Query("SELECT id, name, type, config FROM middlewares")
if err != nil {
return fmt.Errorf("failed to fetch middlewares: %w", err)
}
defer rows.Close()
for rows.Next() {
var id, name, typ, configStr string
if err := rows.Scan(&id, &name, &typ, &configStr); err != nil {
log.Printf("Failed to scan middleware: %v", err)
continue
}
// Skip middlewares not assigned to any resource/router
if allowedIDs != nil {
if _, ok := allowedIDs[id]; !ok {
continue
}
}
var middlewareConfig map[string]interface{}
if err := json.Unmarshal([]byte(configStr), &middlewareConfig); err != nil {
log.Printf("Failed to parse middleware config for %s: %v", name, err)
continue
}
// Use the centralized processing logic from models package
middlewareConfig = models.ProcessMiddlewareConfig(typ, middlewareConfig)
// Add middleware using its name as the key (so chain references by name work)
config.HTTP.Middlewares[name] = map[string]interface{}{
typ: middlewareConfig,
}
if shouldLog() {
log.Printf("Added middleware %s [%s] (%s) to config", name, id, typ)
}
}
return rows.Err()
}
// applyServices adds custom services from the database
func (cp *ConfigProxy) applyServices(config *ProxiedTraefikConfig) error {
rows, err := cp.db.Query("SELECT id, name, type, config FROM services")
if err != nil {
return fmt.Errorf("failed to fetch services: %w", err)
}
defer rows.Close()
for rows.Next() {
var id, name, typ, configStr string
if err := rows.Scan(&id, &name, &typ, &configStr); err != nil {
log.Printf("Failed to scan service: %v", err)
continue
}
var serviceConfig map[string]interface{}
if err := json.Unmarshal([]byte(configStr), &serviceConfig); err != nil {
log.Printf("Failed to parse service config for %s: %v", name, err)
continue
}
// Use the centralized processing logic from models package
serviceConfig = models.ProcessServiceConfig(typ, serviceConfig)
// Determine protocol based on service type and config
protocol := cp.determineServiceProtocol(typ, serviceConfig)
serviceEntry := map[string]interface{}{typ: serviceConfig}
switch protocol {
case "http":
config.HTTP.Services[id] = serviceEntry
case "tcp":
config.TCP.Services[id] = serviceEntry
case "udp":
config.UDP.Services[id] = serviceEntry
}
if shouldLog() {
log.Printf("Added service %s (%s, %s) to config", id, typ, protocol)
}
}
return rows.Err()
}
// applyResourceOverrides applies middleware assignments and other overrides to routers
func (cp *ConfigProxy) applyResourceOverrides(config *ProxiedTraefikConfig, resources []*resourceData, mtlsCfg *mtlsConfigData, securityCfg *securityConfigData) error {
for _, resource := range resources {
// First try to find router by pangolin_router_id (direct match)
routerKey, router := cp.findRouterByPangolinID(config.HTTP.Routers, resource.PangolinRouterID)
// Fall back to host matching if no direct match found
if routerKey == "" {
routerKey, router = cp.findMatchingRouter(config.HTTP.Routers, resource.Host)
}
if routerKey == "" {
// No matching router found, might need to create one
if shouldLog() {
log.Printf("No matching router found for resource %s (pangolin: %s, host: %s)",
resource.ID, resource.PangolinRouterID, resource.Host)
}
continue
}
// Build middleware list (mTLS first, then secure headers, then custom headers, then assigned)
var newMiddlewares []string
if resource.MTLSEnabled && mtlsCfg != nil {
mtlsMiddlewareName, err := cp.ensureResourceMTLSMiddleware(config, resource, mtlsCfg)
if err != nil {
log.Printf("Failed to build mTLS middleware for resource %s: %v", resource.ID, err)
} else if mtlsMiddlewareName != "" {
newMiddlewares = append(newMiddlewares, mtlsMiddlewareName)
// Add mTLS TLS options on the router
if tlsConfig, ok := router["tls"].(map[string]interface{}); ok {
if _, ok := tlsConfig["options"]; !ok {
tlsConfig["options"] = "mtls-verify"
}
} else {
router["tls"] = map[string]interface{}{
"options": "mtls-verify",
}
}
}
}
// Apply TLS hardening if enabled for this resource AND mTLS is NOT enabled
// (mTLS already includes TLS hardening via mtls-verify options)
if resource.TLSHardeningEnabled && !resource.MTLSEnabled {
if tlsConfig, ok := router["tls"].(map[string]interface{}); ok {
tlsConfig["options"] = "tls-hardened"
} else {
router["tls"] = map[string]interface{}{
"options": "tls-hardened",
}
}
}
// Add secure headers middleware if enabled for this resource
if resource.SecureHeadersEnabled && securityCfg != nil && securityCfg.SecureHeadersEnabled {
secureHeadersMiddlewareName := cp.ensureSecureHeadersMiddleware(config, resource, securityCfg)
if secureHeadersMiddlewareName != "" {
newMiddlewares = append(newMiddlewares, secureHeadersMiddlewareName)
}
}
// Add custom headers middleware if configured
if resource.CustomHeaders != "" && resource.CustomHeaders != "{}" && resource.CustomHeaders != "null" {
var headersMap map[string]string
if err := json.Unmarshal([]byte(resource.CustomHeaders), &headersMap); err == nil && len(headersMap) > 0 {
middlewareName := fmt.Sprintf("%s-customheaders", resource.ID)
config.HTTP.Middlewares[middlewareName] = map[string]interface{}{
"headers": map[string]interface{}{"customRequestHeaders": headersMap},
}
newMiddlewares = append(newMiddlewares, middlewareName)
}
}
// Build a combined list of internal + external middlewares sorted by priority
type middlewareEntry struct {
Name string
Priority int
}
var allAssigned []middlewareEntry
for _, mw := range resource.Middlewares {
allAssigned = append(allAssigned, middlewareEntry{Name: mw.Name, Priority: mw.Priority})
}
for _, ext := range resource.ExternalMiddlewares {
allAssigned = append(allAssigned, middlewareEntry{Name: ext.Name, Priority: ext.Priority})
}
// Sort by priority (highest first) for consistent ordering
sort.SliceStable(allAssigned, func(i, j int) bool {
return allAssigned[i].Priority > allAssigned[j].Priority
})
// Add all assigned middlewares (internal by ID, external by name)
for _, entry := range allAssigned {
newMiddlewares = append(newMiddlewares, entry.Name)
}
// Get existing middlewares from router
existingMiddlewares := cp.getRouterMiddlewares(router)
// Merge middlewares (MW-manager additions first, then existing)
finalMiddlewares := newMiddlewares
for _, em := range existingMiddlewares {
// Avoid duplicates
found := false
for _, nm := range newMiddlewares {
if em == nm {
found = true
break
}
}
if !found {
finalMiddlewares = append(finalMiddlewares, em)
}
}
// Update router
if len(finalMiddlewares) > 0 {
router["middlewares"] = finalMiddlewares
}
// Update priority if customized
if resource.RouterPriority != 100 {
router["priority"] = resource.RouterPriority
}
// Update custom service if configured
if resource.CustomServiceID.Valid && resource.CustomServiceID.String != "" {
router["service"] = resource.CustomServiceID.String
}
config.HTTP.Routers[routerKey] = router
if shouldLog() {
log.Printf("Applied overrides to router %s (resource: %s)", routerKey, resource.ID)
}
}
return nil
}
// ensureResourceMTLSMiddleware builds and registers a per-resource mtlswhitelist middleware
func (cp *ConfigProxy) ensureResourceMTLSMiddleware(config *ProxiedTraefikConfig, resource *resourceData, mtlsCfg *mtlsConfigData) (string, error) {
if mtlsCfg == nil || mtlsCfg.CACertPath == "" {
return "", fmt.Errorf("mTLS enabled for resource %s but no CA certificate configured", resource.ID)
}
pluginConfig := map[string]interface{}{
"caFiles": []string{mtlsCfg.CACertPath},
}
if len(mtlsCfg.Rules) > 0 {
pluginConfig["rules"] = append([]interface{}{}, mtlsCfg.Rules...)
}
if len(mtlsCfg.RequestHeaders) > 0 {
pluginConfig["requestHeaders"] = mtlsCfg.RequestHeaders
}
if mtlsCfg.RejectMessage != "" || mtlsCfg.RejectCode > 0 {
code := mtlsCfg.RejectCode
if code == 0 {
code = 403
}
entry := map[string]interface{}{
"code": code,
}
if mtlsCfg.RejectMessage != "" {
entry["message"] = mtlsCfg.RejectMessage
}
pluginConfig["rejectMessage"] = entry
}
if mtlsCfg.RefreshInterval != "" {
pluginConfig["refreshInterval"] = mtlsCfg.RefreshInterval
}
// Resource-level overrides
if resource.MTLSRules.Valid && strings.TrimSpace(resource.MTLSRules.String) != "" {
var rules []interface{}
if err := json.Unmarshal([]byte(resource.MTLSRules.String), &rules); err == nil {
pluginConfig["rules"] = rules
} else {
log.Printf("Failed to parse mtls_rules for resource %s: %v", resource.ID, err)
}
}
if resource.MTLSRequestHdrs.Valid && strings.TrimSpace(resource.MTLSRequestHdrs.String) != "" {
var headers map[string]string
if err := json.Unmarshal([]byte(resource.MTLSRequestHdrs.String), &headers); err == nil && len(headers) > 0 {
pluginConfig["requestHeaders"] = headers
} else if err != nil {
log.Printf("Failed to parse mtls_request_headers for resource %s: %v", resource.ID, err)
}
}
// Reject message + code
if (resource.MTLSRejectMsg.Valid && strings.TrimSpace(resource.MTLSRejectMsg.String) != "") || resource.MTLSRejectCode.Valid {
code := mtlsCfg.RejectCode
if resource.MTLSRejectCode.Valid {
code = int(resource.MTLSRejectCode.Int64)
}
if code == 0 {
code = 403
}
entry := map[string]interface{}{
"code": code,
}
if resource.MTLSRejectMsg.Valid && strings.TrimSpace(resource.MTLSRejectMsg.String) != "" {
entry["message"] = resource.MTLSRejectMsg.String
}
pluginConfig["rejectMessage"] = entry
}
if resource.MTLSRefresh.Valid && strings.TrimSpace(resource.MTLSRefresh.String) != "" {
pluginConfig["refreshInterval"] = resource.MTLSRefresh.String
}
if resource.MTLSExternal.Valid && strings.TrimSpace(resource.MTLSExternal.String) != "" {
var external map[string]interface{}
if err := json.Unmarshal([]byte(resource.MTLSExternal.String), &external); err == nil {
pluginConfig["externalData"] = external
} else {
log.Printf("Failed to parse mtls_external_data for resource %s: %v", resource.ID, err)
}
}
middlewareName := fmt.Sprintf("%s-mtlsauth", resource.ID)
config.HTTP.Middlewares[middlewareName] = map[string]interface{}{
"plugin": map[string]interface{}{
"mtlswhitelist": pluginConfig,
},
}
return middlewareName, nil
}
// fetchResourceData loads active resources and their middleware assignments
func (cp *ConfigProxy) fetchResourceData() ([]*resourceData, error) {
query := `
SELECT r.id, COALESCE(r.pangolin_router_id, r.id), r.host, r.service_id, r.entrypoints, r.tls_domains,
r.custom_headers, r.router_priority, r.source_type, r.mtls_enabled,
r.mtls_rules, r.mtls_request_headers, r.mtls_reject_message, r.mtls_reject_code,
r.mtls_refresh_interval, r.mtls_external_data,
COALESCE(r.tls_hardening_enabled, 0), COALESCE(r.secure_headers_enabled, 0),
rm.middleware_id, rm.priority, m.name as middleware_name,
rs.service_id as custom_service_id
FROM resources r
LEFT JOIN resource_middlewares rm ON r.id = rm.resource_id
LEFT JOIN middlewares m ON rm.middleware_id = m.id
LEFT JOIN resource_services rs ON r.id = rs.resource_id
WHERE r.status = 'active'
ORDER BY r.id, rm.priority DESC
`
rows, err := cp.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
resourceMap := make(map[string]*resourceData)
for rows.Next() {
var rID, pangolinRouterID, host, serviceID, entrypoints, tlsDomains, customHeaders, sourceType string
var routerPriority sql.NullInt64
var mtlsEnabled, tlsHardeningEnabled, secureHeadersEnabled int
var middlewareID sql.NullString
var middlewarePriority sql.NullInt64
var middlewareName sql.NullString
var customServiceID sql.NullString
var mtlsRules, mtlsRequestHeaders, mtlsRejectMessage, mtlsRefreshInterval, mtlsExternalData sql.NullString
var mtlsRejectCode sql.NullInt64
err := rows.Scan(
&rID, &pangolinRouterID, &host, &serviceID, &entrypoints, &tlsDomains,
&customHeaders, &routerPriority, &sourceType, &mtlsEnabled,
&mtlsRules, &mtlsRequestHeaders, &mtlsRejectMessage, &mtlsRejectCode,
&mtlsRefreshInterval, &mtlsExternalData,
&tlsHardeningEnabled, &secureHeadersEnabled,
&middlewareID, &middlewarePriority, &middlewareName, &customServiceID,
)
if err != nil {
log.Printf("Failed to scan resource: %v", err)
continue
}
data, exists := resourceMap[rID]
if !exists {
priority := 100
if routerPriority.Valid {
priority = int(routerPriority.Int64)
}
data = &resourceData{
ID: rID,
PangolinRouterID: pangolinRouterID,
Host: host,
ServiceID: serviceID,
Entrypoints: entrypoints,
TLSDomains: tlsDomains,
CustomHeaders: customHeaders,
RouterPriority: priority,
SourceType: sourceType,
MTLSEnabled: mtlsEnabled == 1,
TLSHardeningEnabled: tlsHardeningEnabled == 1,
SecureHeadersEnabled: secureHeadersEnabled == 1,
CustomServiceID: customServiceID,
MTLSRules: mtlsRules,
MTLSRequestHdrs: mtlsRequestHeaders,
MTLSRejectMsg: mtlsRejectMessage,
MTLSRejectCode: mtlsRejectCode,
MTLSRefresh: mtlsRefreshInterval,
MTLSExternal: mtlsExternalData,
}
resourceMap[rID] = data
}
if middlewareID.Valid {
mwPriority := 100
if middlewarePriority.Valid {
mwPriority = int(middlewarePriority.Int64)
}
mwName := middlewareID.String // fallback to ID if name not available
if middlewareName.Valid && middlewareName.String != "" {
mwName = middlewareName.String
}
data.Middlewares = append(data.Middlewares, middlewareWithPriority{
ID: middlewareID.String,
Name: mwName,
Priority: mwPriority,
})
}
}
if err := rows.Err(); err != nil {
return nil, err
}
// Load external (Traefik-native) middleware assignments
extRows, err := cp.db.Query(
"SELECT resource_id, middleware_name, priority FROM resource_external_middlewares ORDER BY resource_id, priority DESC",
)
if err != nil {
log.Printf("Warning: failed to fetch external middlewares: %v", err)
} else {
defer extRows.Close()
for extRows.Next() {
var resID, name string
var priority int
if err := extRows.Scan(&resID, &name, &priority); err != nil {
log.Printf("Failed to scan external middleware: %v", err)
continue
}
if data, ok := resourceMap[resID]; ok {
data.ExternalMiddlewares = append(data.ExternalMiddlewares, externalMiddlewareRef{
Name: name,
Priority: priority,
})
}
}
}
resources := make([]*resourceData, 0, len(resourceMap))
for _, r := range resourceMap {
resources = append(resources, r)
}
return resources, nil
}
// loadGlobalMTLSConfig retrieves global mTLS settings (including plugin defaults).
func (cp *ConfigProxy) loadGlobalMTLSConfig() (*mtlsConfigData, error) {
var enabled int
var caCertPath string
var middlewareRules, middlewareRequestHeaders, middlewareRejectMessage sql.NullString
var middlewareRefreshInterval sql.NullInt64
err := cp.db.QueryRow(`
SELECT enabled, ca_cert_path, middleware_rules, middleware_request_headers,
middleware_reject_message, middleware_refresh_interval
FROM mtls_config WHERE id = 1
`).Scan(&enabled, &caCertPath, &middlewareRules, &middlewareRequestHeaders,
&middlewareRejectMessage, &middlewareRefreshInterval)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("failed to check mTLS config: %w", err)
}
if enabled != 1 || caCertPath == "" {
return nil, nil
}
cfg := &mtlsConfigData{
CACertPath: caCertPath,
RejectCode: 403,
}
if middlewareRules.Valid && middlewareRules.String != "" {
var rules []interface{}
if err := json.Unmarshal([]byte(middlewareRules.String), &rules); err == nil {
cfg.Rules = rules
}
}
if middlewareRequestHeaders.Valid && middlewareRequestHeaders.String != "" {
var headers map[string]string
if err := json.Unmarshal([]byte(middlewareRequestHeaders.String), &headers); err == nil && len(headers) > 0 {
cfg.RequestHeaders = headers
}
}
if middlewareRejectMessage.Valid && middlewareRejectMessage.String != "" {
cfg.RejectMessage = middlewareRejectMessage.String
}
if middlewareRefreshInterval.Valid && middlewareRefreshInterval.Int64 > 0 {
cfg.RefreshInterval = fmt.Sprintf("%ds", middlewareRefreshInterval.Int64)
}
return cfg, nil
}
// applyTLSOptions adds TLS options for mTLS verification with hardened security settings
func (cp *ConfigProxy) applyTLSOptions(config *ProxiedTraefikConfig, mtlsCfg *mtlsConfigData) {
if mtlsCfg == nil || mtlsCfg.CACertPath == "" {
return
}
config.TLS.Options["mtls-verify"] = map[string]interface{}{
"clientAuth": map[string]interface{}{
"caFiles": []string{mtlsCfg.CACertPath},
"clientAuthType": "VerifyClientCertIfGiven",
},
"minVersion": "VersionTLS12",
"maxVersion": "VersionTLS13",
"sniStrict": true,
"cipherSuites": []string{
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
"TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
},
"curvePreferences": []string{
"X25519",
"CurveP384",
"CurveP521",
},
}
}
// findRouterByPangolinID finds a router by its Pangolin router ID (direct name match).
// Prefers the main websecure router over redirect routers (-redirect suffix).
func (cp *ConfigProxy) findRouterByPangolinID(routers map[string]interface{}, pangolinRouterID string) (string, map[string]interface{}) {
if pangolinRouterID == "" {
return "", nil
}
// Try direct match first
if routerConfig, ok := routers[pangolinRouterID]; ok {
if router, ok := routerConfig.(map[string]interface{}); ok {
// Verify it's not a redirect router - prefer websecure
if !strings.HasSuffix(pangolinRouterID, "-redirect") {
return pangolinRouterID, router
}
}
}
// Try to find the non-redirect version
// If pangolinRouterID ends with "-redirect", try the base name
baseName := strings.TrimSuffix(pangolinRouterID, "-redirect")
if baseName != pangolinRouterID {
if routerConfig, ok := routers[baseName]; ok {
if router, ok := routerConfig.(map[string]interface{}); ok {
return baseName, router
}
}
}
// Try the -redirect version if we have the base name
redirectName := pangolinRouterID + "-redirect"
if routerConfig, ok := routers[redirectName]; ok {
if router, ok := routerConfig.(map[string]interface{}); ok {
// But only return redirect router if we can't find the main one
if _, ok := routers[pangolinRouterID]; !ok {
return redirectName, router
}
}
}
// Return direct match even if it's a redirect router (better than nothing)
if routerConfig, ok := routers[pangolinRouterID]; ok {
if router, ok := routerConfig.(map[string]interface{}); ok {
return pangolinRouterID, router
}
}
return "", nil
}
// findMatchingRouter finds a router that matches the given host.
// Prefers the main websecure router over redirect routers (-redirect suffix).
// This ensures middlewares are applied to the HTTPS router, not the HTTP->HTTPS redirect router.
func (cp *ConfigProxy) findMatchingRouter(routers map[string]interface{}, host string) (string, map[string]interface{}) {
// Host matching regex
hostRegex := regexp.MustCompile(`Host\(\x60([^` + "`" + `]+)\x60\)`)
// Collect all matching routers first
type matchedRouter struct {
name string
router map[string]interface{}
}
var matches []matchedRouter
for routerName, routerConfig := range routers {
router, ok := routerConfig.(map[string]interface{})
if !ok {
continue
}
rule, ok := router["rule"].(string)
if !ok {
continue
}
// Extract host from rule
hostMatches := hostRegex.FindStringSubmatch(rule)
if len(hostMatches) > 1 && hostMatches[1] == host {
matches = append(matches, matchedRouter{name: routerName, router: router})
}
}
if len(matches) == 0 {
return "", nil
}
// Prefer the main router (websecure) over redirect routers
// Main routers don't have the "-redirect" suffix
for _, m := range matches {
if !strings.HasSuffix(m.name, "-redirect") {
// Also verify it has websecure entrypoint for extra safety
if eps := cp.getRouterEntryPoints(m.router); len(eps) > 0 {
for _, ep := range eps {
if ep == "websecure" {
return m.name, m.router
}
}
}
// Even without websecure check, prefer non-redirect routers
return m.name, m.router
}
}
// Fallback to first match if no non-redirect router found
return matches[0].name, matches[0].router
}
// getRouterEntryPoints extracts the entryPoints list from a router config
func (cp *ConfigProxy) getRouterEntryPoints(router map[string]interface{}) []string {
entryPoints, ok := router["entryPoints"]
if !ok {
return nil
}
switch v := entryPoints.(type) {
case []interface{}:
result := make([]string, 0, len(v))
for _, ep := range v {
if s, ok := ep.(string); ok {
result = append(result, s)
}
}
return result
case []string:
return v
default:
return nil
}
}
// getRouterMiddlewares extracts the middleware list from a router config
func (cp *ConfigProxy) getRouterMiddlewares(router map[string]interface{}) []string {
middlewares, ok := router["middlewares"]
if !ok {
return nil
}
switch v := middlewares.(type) {
case []interface{}:
result := make([]string, 0, len(v))
for _, m := range v {
if s, ok := m.(string); ok {
result = append(result, s)
}
}
return result
case []string:
return v
default:
return nil
}
}
// determineServiceProtocol determines which protocol section a service belongs to
func (cp *ConfigProxy) determineServiceProtocol(serviceType string, config map[string]interface{}) string {
if serviceType == string(models.LoadBalancerType) {
if servers, ok := config["servers"].([]interface{}); ok {
for _, s := range servers {
if serverMap, ok := s.(map[string]interface{}); ok {
if _, hasAddress := serverMap["address"]; hasAddress {
return "tcp"
}
if _, hasURL := serverMap["url"]; hasURL {
return "http"
}
}
}
}
}
return "http"
}
// sanitizeMTLSWhitelist ensures requestHeaders is a map for all mtlswhitelist middlewares
func (cp *ConfigProxy) sanitizeMTLSWhitelist(config *ProxiedTraefikConfig) {
if config == nil || config.HTTP == nil {
return
}
for key, mw := range config.HTTP.Middlewares {
mwMap, ok := mw.(map[string]interface{})
if !ok {
continue
}
pluginVal, ok := mwMap["plugin"].(map[string]interface{})
if !ok {
continue
}
mtlsVal, ok := pluginVal["mtlswhitelist"].(map[string]interface{})
if !ok {
continue
}
if rh, exists := mtlsVal["requestHeaders"]; exists {
switch v := rh.(type) {
case map[string]interface{}:
// ok
if len(v) == 0 {
delete(mtlsVal, "requestHeaders")
}
case map[string]string:
if len(v) == 0 {
delete(mtlsVal, "requestHeaders")
} else {
mtlsVal["requestHeaders"] = v
}
case string:
// Traefik plugin expects a map; replace string with empty map
delete(mtlsVal, "requestHeaders")
if shouldLog() {
log.Printf("Sanitized mtlswhitelist.requestHeaders for middleware %s (was string)", key)
}
default:
delete(mtlsVal, "requestHeaders")
if shouldLog() {
log.Printf("Sanitized mtlswhitelist.requestHeaders for middleware %s (was %T)", key, v)
}
}
}
}
}
// SetPangolinURL updates the Pangolin API URL
func (cp *ConfigProxy) SetPangolinURL(url string) {
cp.pangolinURL = url
cp.InvalidateCache()
}
// SetCacheDuration updates the cache duration
func (cp *ConfigProxy) SetCacheDuration(duration time.Duration) {
cp.cacheMutex.Lock()
defer cp.cacheMutex.Unlock()
cp.cacheDuration = duration
}
// normalizeRouterOrder converts all HTTP routers to OrderedRouter structs
// to ensure consistent JSON field ordering matching Pangolin's output.
func (cp *ConfigProxy) normalizeRouterOrder(config *ProxiedTraefikConfig) {
if config == nil || config.HTTP == nil || config.HTTP.Routers == nil {
return
}
for routerKey, routerVal := range config.HTTP.Routers {
router, ok := routerVal.(map[string]interface{})
if !ok {
continue
}
ordered := cp.mapToOrderedRouter(router)
config.HTTP.Routers[routerKey] = ordered
}
}
// mapToOrderedRouter converts a map[string]interface{} router to OrderedRouter
func (cp *ConfigProxy) mapToOrderedRouter(router map[string]interface{}) *OrderedRouter {
ordered := &OrderedRouter{}
// EntryPoints
if eps, ok := router["entryPoints"]; ok {
switch v := eps.(type) {
case []interface{}:
for _, ep := range v {
if s, ok := ep.(string); ok {
ordered.EntryPoints = append(ordered.EntryPoints, s)
}
}
case []string:
ordered.EntryPoints = v
}
}
// Middlewares
if mws, ok := router["middlewares"]; ok {
switch v := mws.(type) {
case []interface{}:
for _, mw := range v {
if s, ok := mw.(string); ok {
ordered.Middlewares = append(ordered.Middlewares, s)
}
}
case []string:
ordered.Middlewares = v
}
}
// Service
if svc, ok := router["service"].(string); ok {
ordered.Service = svc
}
// Rule
if rule, ok := router["rule"].(string); ok {
ordered.Rule = rule
}
// Priority
if priority, ok := router["priority"]; ok {
switch v := priority.(type) {
case int:
ordered.Priority = v
case float64:
ordered.Priority = int(v)
case int64:
ordered.Priority = int(v)
}
}
// TLS
if tlsVal, ok := router["tls"]; ok {
switch tls := tlsVal.(type) {
case map[string]interface{}:
ordered.TLS = cp.mapToOrderedTLS(tls)
}
}
return ordered
}
// mapToOrderedTLS converts a map[string]interface{} TLS config to OrderedTLSConfig
func (cp *ConfigProxy) mapToOrderedTLS(tls map[string]interface{}) *OrderedTLSConfig {
ordered := &OrderedTLSConfig{}
// CertResolver
if cr, ok := tls["certResolver"].(string); ok {
ordered.CertResolver = cr
}
// Domains
if domains, ok := tls["domains"]; ok {
switch v := domains.(type) {
case []interface{}:
for _, d := range v {
if s, ok := d.(string); ok {
ordered.Domains = append(ordered.Domains, s)
}
}
case []string:
ordered.Domains = v
}
}
// Options
if opts, ok := tls["options"].(string); ok {
ordered.Options = opts
}
return ordered
}
// normalizeMiddlewareOrder converts HTTP middlewares to OrderedMiddleware structs
// to ensure consistent JSON field ordering matching Pangolin's output.
// Only converts middlewares with known field structures (redirectScheme, plugin, headers).
// Other middleware types are preserved as-is to avoid losing their configuration.
func (cp *ConfigProxy) normalizeMiddlewareOrder(config *ProxiedTraefikConfig) {
if config == nil || config.HTTP == nil || config.HTTP.Middlewares == nil {
return
}
for mwKey, mwVal := range config.HTTP.Middlewares {
mw, ok := mwVal.(map[string]interface{})
if !ok {
continue
}
// Only convert middlewares that have fields we support in OrderedMiddleware
// This preserves other middleware types (basicAuth, rateLimit, ipAllowList, etc.)
if cp.isOrderableMiddleware(mw) {
ordered := cp.mapToOrderedMiddleware(mw)
config.HTTP.Middlewares[mwKey] = ordered
}
// Otherwise, keep the middleware as-is (map[string]interface{})
}
}
// isOrderableMiddleware checks if a middleware has fields that can be converted
// to OrderedMiddleware without losing data.
func (cp *ConfigProxy) isOrderableMiddleware(mw map[string]interface{}) bool {
// Only convert if it ONLY contains fields we support
for key := range mw {
switch key {
case "redirectScheme", "plugin", "headers":
// These are supported
default:
// Contains unsupported field, don't convert
return false
}
}
return true
}
// mapToOrderedMiddleware converts a map[string]interface{} middleware to OrderedMiddleware
func (cp *ConfigProxy) mapToOrderedMiddleware(mw map[string]interface{}) *OrderedMiddleware {
ordered := &OrderedMiddleware{}
// RedirectScheme (comes first in Pangolin output)
if rs, ok := mw["redirectScheme"].(map[string]interface{}); ok {
ordered.RedirectScheme = rs
}
// Plugin (comes second in Pangolin output)
if plugin, ok := mw["plugin"].(map[string]interface{}); ok {
ordered.Plugin = plugin
}
// Headers (for custom headers middleware)
if headers, ok := mw["headers"].(map[string]interface{}); ok {
ordered.Headers = headers
}
return ordered
}
// loadSecurityConfig loads global security configuration from the database
func (cp *ConfigProxy) loadSecurityConfig() (*securityConfigData, error) {
var tlsHardeningEnabled, secureHeadersEnabled int
var xContentTypeOptions, xFrameOptions, xXSSProtection, hsts, referrerPolicy, csp, permissionsPolicy string
err := cp.db.QueryRow(`
SELECT tls_hardening_enabled, secure_headers_enabled,
secure_headers_x_content_type_options, secure_headers_x_frame_options,
secure_headers_x_xss_protection, secure_headers_hsts,
secure_headers_referrer_policy, secure_headers_csp,
secure_headers_permissions_policy
FROM security_config WHERE id = 1
`).Scan(
&tlsHardeningEnabled, &secureHeadersEnabled,
&xContentTypeOptions, &xFrameOptions,
&xXSSProtection, &hsts,
&referrerPolicy, &csp,
&permissionsPolicy,
)
if err != nil {
if err == sql.ErrNoRows {
// Return defaults
return &securityConfigData{
TLSHardeningEnabled: false,
SecureHeadersEnabled: false,
SecureHeaders: models.DefaultSecureHeaders(),
}, nil
}
return nil, fmt.Errorf("failed to load security config: %w", err)
}
return &securityConfigData{
TLSHardeningEnabled: tlsHardeningEnabled == 1,
SecureHeadersEnabled: secureHeadersEnabled == 1,
SecureHeaders: models.SecureHeadersConfig{
XContentTypeOptions: xContentTypeOptions,
XFrameOptions: xFrameOptions,
XXSSProtection: xXSSProtection,
HSTS: hsts,
ReferrerPolicy: referrerPolicy,
CSP: csp,
PermissionsPolicy: permissionsPolicy,
},
}, nil
}
// applyTLSHardeningOptions adds TLS options for hardened security (without client auth)
func (cp *ConfigProxy) applyTLSHardeningOptions(config *ProxiedTraefikConfig) {
config.TLS.Options["tls-hardened"] = models.TLSHardeningOptions()
}
// ensureSecureHeadersMiddleware creates and registers a secure headers middleware for a resource
func (cp *ConfigProxy) ensureSecureHeadersMiddleware(config *ProxiedTraefikConfig, resource *resourceData, securityCfg *securityConfigData) string {
if securityCfg == nil {
return ""
}
customResponseHeaders := make(map[string]string)
// Only add headers that have values configured
if securityCfg.SecureHeaders.XContentTypeOptions != "" {
customResponseHeaders["X-Content-Type-Options"] = securityCfg.SecureHeaders.XContentTypeOptions
}
if securityCfg.SecureHeaders.XFrameOptions != "" {
customResponseHeaders["X-Frame-Options"] = securityCfg.SecureHeaders.XFrameOptions
}
if securityCfg.SecureHeaders.XXSSProtection != "" {
customResponseHeaders["X-XSS-Protection"] = securityCfg.SecureHeaders.XXSSProtection
}
if securityCfg.SecureHeaders.HSTS != "" {
customResponseHeaders["Strict-Transport-Security"] = securityCfg.SecureHeaders.HSTS
}
if securityCfg.SecureHeaders.ReferrerPolicy != "" {
customResponseHeaders["Referrer-Policy"] = securityCfg.SecureHeaders.ReferrerPolicy
}
if securityCfg.SecureHeaders.CSP != "" {
customResponseHeaders["Content-Security-Policy"] = securityCfg.SecureHeaders.CSP
}
if securityCfg.SecureHeaders.PermissionsPolicy != "" {
customResponseHeaders["Permissions-Policy"] = securityCfg.SecureHeaders.PermissionsPolicy
}
// Skip if no headers configured
if len(customResponseHeaders) == 0 {
return ""
}
middlewareName := fmt.Sprintf("%s-secureheaders", resource.ID)
config.HTTP.Middlewares[middlewareName] = map[string]interface{}{
"headers": map[string]interface{}{
"customResponseHeaders": customResponseHeaders,
},
}
return middlewareName
}