mirror of
https://github.com/hhftechnology/middleware-manager.git
synced 2026-07-16 11:28:25 +00:00
Key middleware entries in generated and proxied Traefik configs by middleware name (so chain references by name work) instead of by ID. Join middleware names in resource queries and scan them (with fallback to ID when name is not available), add Name fields to middleware structs, and use the extracted base name when building final middleware references. Also update log messages to include both name and ID. In the UI, enforce and auto-sanitize middleware names to lowercase a-z0-9- and hyphens only.
1443 lines
43 KiB
Go
1443 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: &http.Client{
|
|
Timeout: 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
|
|
}
|
|
cp.cacheMutex.RUnlock()
|
|
|
|
// Acquire write lock for cache update
|
|
cp.cacheMutex.Lock()
|
|
defer cp.cacheMutex.Unlock()
|
|
|
|
// Double-check after acquiring write lock
|
|
if cp.cache != nil && time.Now().Before(cp.cacheExpiry) {
|
|
return cp.cache, nil
|
|
}
|
|
|
|
// Fetch fresh config from Pangolin
|
|
config, err := cp.fetchPangolinConfig()
|
|
if err != nil {
|
|
// Return stale cache on error if available
|
|
if cp.cache != nil {
|
|
log.Printf("Warning: Pangolin fetch failed, using stale cache: %v", err)
|
|
return cp.cache, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to fetch Pangolin config: %w", err)
|
|
}
|
|
|
|
// Merge MW-manager additions
|
|
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)
|
|
|
|
// Update cache
|
|
cp.cache = config
|
|
cp.cacheExpiry = time.Now().Add(cp.cacheDuration)
|
|
|
|
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(resp.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.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
|
|
}
|