diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 497211d2c..f4dee9822 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -3,9 +3,7 @@ name: Build and Push Docker Image
on:
push:
branches:
- - main
- - master
- - dev
+ - sni-support
paths:
- 'Dockerfile'
- 'go.mod'
@@ -19,9 +17,6 @@ on:
- 'models/**'
- 'services/**'
- 'ui/**'
-
- tags:
- - 'v*'
workflow_dispatch:
env:
@@ -53,48 +48,14 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- # Get current date for image tags
- - name: Get current date
- id: date
- run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT
-
# Prepare tags based on branch and version
- name: Prepare Docker tags
id: docker_tags
run: |
TAGS=""
-
- # Add branch-specific tags
- if [[ "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "master" ]]; then
- # For main/master branch, add latest tag
- TAGS="$TAGS ${{ env.DOCKERHUB_IMAGE_NAME }}:latest"
- elif [[ "${{ github.ref_name }}" == "dev" ]]; then
- # For dev branch
- TAGS="$TAGS ${{ env.DOCKERHUB_IMAGE_NAME }}:dev"
- elif [[ "${{ github.ref_type }}" == "branch" ]]; then
- # For other branches
- TAGS="$TAGS ${{ env.DOCKERHUB_IMAGE_NAME }}:${{ github.ref_name }}"
- fi
-
- # Add sha tag for all branches
- if [[ "${{ github.ref_type }}" == "branch" ]]; then
- TAGS="$TAGS,${{ env.DOCKERHUB_IMAGE_NAME }}:sha-${GITHUB_SHA::7}"
- fi
-
- # Add version tag for tagged releases
- if [[ "${{ github.ref_type }}" == "tag" && "${{ github.ref }}" == refs/tags/v* ]]; then
- VERSION="${{ github.ref_name }}"
-
- # Add full version tag
- TAGS="$TAGS,${{ env.DOCKERHUB_IMAGE_NAME }}:$VERSION"
+ if [[ "${{ github.ref_type }}" == "branch" && "${{ github.ref_name }}" == "sni-support" ]]; then
+ TAGS="${{ env.DOCKERHUB_IMAGE_NAME }}:sni-support"
fi
-
- # Add date tag for all builds
- TAGS="$TAGS,${{ env.DOCKERHUB_IMAGE_NAME }}:${{ steps.date.outputs.date }}"
-
- # Remove leading space or comma if present
- TAGS=$(echo "$TAGS" | sed 's/^[ ,]*//')
-
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "Docker tags: $TAGS"
@@ -102,7 +63,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
- push: true
+ push: ${{ steps.docker_tags.outputs.tags != '' }} # IMPORTANT: Only push if tags were generated
platforms: linux/amd64,linux/arm64
tags: ${{ steps.docker_tags.outputs.tags }}
cache-from: type=gha
diff --git a/README.md b/README.md
index 55c19157f..d9994134b 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,22 @@
-# Pangolin Middleware Manager
+
+
+Middleware/Router Manager for your Pangolin Stack
+
+
+
+
+
+
A specialized microservice that enhances your Pangolin deployment by enabling custom Traefik middleware attachment to resources without modifying Pangolin itself. This provides crucial functionality for implementing authentication, security headers, rate limiting, and other middleware-based protections.
diff --git a/api/handlers.go b/api/handlers.go
index bac36d67a..8497d420b 100644
--- a/api/handlers.go
+++ b/api/handlers.go
@@ -8,6 +8,7 @@ import (
"fmt"
"log"
"net/http"
+ "strings"
"time"
"github.com/gin-gonic/gin"
@@ -82,30 +83,41 @@ func (s *Server) createMiddleware(c *gin.Context) {
}
// If something goes wrong, rollback
+ var txErr error
defer func() {
- if err != nil {
+ if txErr != nil {
tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
- _, err = tx.Exec(
+ log.Printf("Attempting to insert middleware with ID=%s, name=%s, type=%s",
+ id, middleware.Name, middleware.Type)
+
+ result, txErr := tx.Exec(
"INSERT INTO middlewares (id, name, type, config) VALUES (?, ?, ?, ?)",
id, middleware.Name, middleware.Type, string(configJSON),
)
- if err != nil {
- log.Printf("Error inserting middleware: %v", err)
+ if txErr != nil {
+ log.Printf("Error inserting middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to save middleware")
return
}
+ rowsAffected, err := result.RowsAffected()
+ if err == nil {
+ log.Printf("Insert affected %d rows", rowsAffected)
+ }
+
// Commit the transaction
- if err = tx.Commit(); err != nil {
- log.Printf("Error committing transaction: %v", err)
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
+ log.Printf("Successfully created middleware %s (%s)", middleware.Name, id)
c.JSON(http.StatusCreated, gin.H{
"id": id,
"name": middleware.Name,
@@ -136,6 +148,90 @@ func (s *Server) getMiddleware(c *gin.Context) {
c.JSON(http.StatusOK, middleware)
}
+// updateRouterPriority updates the router priority for a resource
+func (s *Server) updateRouterPriority(c *gin.Context) {
+ id := c.Param("id")
+ if id == "" {
+ ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
+ return
+ }
+
+ var input struct {
+ RouterPriority int `json:"router_priority" binding:"required"`
+ }
+
+ if err := c.ShouldBindJSON(&input); err != nil {
+ ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
+ return
+ }
+
+ // Verify resource exists and is active
+ var exists int
+ var status string
+ err := s.db.QueryRow("SELECT 1, status FROM resources WHERE id = ?", id).Scan(&exists, &status)
+ if err == sql.ErrNoRows {
+ ResponseWithError(c, http.StatusNotFound, "Resource not found")
+ return
+ } else if err != nil {
+ log.Printf("Error checking resource existence: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ // Don't allow updating disabled resources
+ if status == "disabled" {
+ ResponseWithError(c, http.StatusBadRequest, "Cannot update a disabled resource")
+ return
+ }
+
+ // Update the resource within a transaction
+ tx, err := s.db.Begin()
+ if err != nil {
+ log.Printf("Error beginning transaction: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ var txErr error
+ defer func() {
+ if txErr != nil {
+ tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
+ }
+ }()
+
+ log.Printf("Updating router priority for resource %s to %d", id, input.RouterPriority)
+
+ result, txErr := tx.Exec(
+ "UPDATE resources SET router_priority = ?, updated_at = ? WHERE id = ?",
+ input.RouterPriority, time.Now(), id,
+ )
+
+ if txErr != nil {
+ log.Printf("Error updating router priority: %v", txErr)
+ ResponseWithError(c, http.StatusInternalServerError, "Failed to update router priority")
+ return
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err == nil {
+ log.Printf("Update affected %d rows", rowsAffected)
+ }
+
+ // Commit the transaction
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ log.Printf("Successfully updated router priority for resource %s", id)
+ c.JSON(http.StatusOK, gin.H{
+ "id": id,
+ "router_priority": input.RouterPriority,
+ })
+}
+
// updateMiddleware updates a middleware configuration
func (s *Server) updateMiddleware(c *gin.Context) {
id := c.Param("id")
@@ -190,30 +286,55 @@ func (s *Server) updateMiddleware(c *gin.Context) {
}
// If something goes wrong, rollback
+ var txErr error
defer func() {
- if err != nil {
+ if txErr != nil {
tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
- _, err = tx.Exec(
+ log.Printf("Attempting to update middleware %s with name=%s, type=%s",
+ id, middleware.Name, middleware.Type)
+
+ result, txErr := tx.Exec(
"UPDATE middlewares SET name = ?, type = ?, config = ?, updated_at = ? WHERE id = ?",
middleware.Name, middleware.Type, string(configJSON), time.Now(), id,
)
- if err != nil {
- log.Printf("Error updating middleware: %v", err)
+ if txErr != nil {
+ log.Printf("Error updating middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to update middleware")
return
}
+ rowsAffected, err := result.RowsAffected()
+ if err == nil {
+ log.Printf("Update affected %d rows", rowsAffected)
+ if rowsAffected == 0 {
+ log.Printf("Warning: Update query succeeded but no rows were affected")
+ }
+ }
+
// Commit the transaction
- if err = tx.Commit(); err != nil {
- log.Printf("Error committing transaction: %v", err)
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
+ // Double-check that the middleware was updated
+ var updatedName string
+ err = s.db.QueryRow("SELECT name FROM middlewares WHERE id = ?", id).Scan(&updatedName)
+ if err != nil {
+ log.Printf("Warning: Could not verify middleware update: %v", err)
+ } else if updatedName != middleware.Name {
+ log.Printf("Warning: Name mismatch after update. Expected '%s', got '%s'", middleware.Name, updatedName)
+ } else {
+ log.Printf("Successfully verified middleware update for %s", id)
+ }
+
+ // Return the updated middleware
c.JSON(http.StatusOK, gin.H{
"id": id,
"name": middleware.Name,
@@ -221,7 +342,61 @@ func (s *Server) updateMiddleware(c *gin.Context) {
"config": middleware.Config,
})
}
+// sanitizeMiddlewareConfig ensures proper formatting of duration values and strings
+func sanitizeMiddlewareConfig(config map[string]interface{}) {
+ // List of keys that should be treated as duration values
+ durationKeys := map[string]bool{
+ "checkPeriod": true,
+ "fallbackDuration": true,
+ "recoveryDuration": true,
+ "initialInterval": true,
+ "retryTimeout": true,
+ "gracePeriod": true,
+ }
+
+ // Process the configuration recursively
+ sanitizeConfigRecursive(config, durationKeys)
+}
+// sanitizeConfigRecursive processes config values recursively
+func sanitizeConfigRecursive(data interface{}, durationKeys map[string]bool) {
+ // Process based on data type
+ switch v := data.(type) {
+ case map[string]interface{}:
+ // Process each key-value pair in the map
+ for key, value := range v {
+ // Handle different value types
+ switch innerVal := value.(type) {
+ case string:
+ // Check if this is a duration field and ensure proper format
+ if durationKeys[key] {
+ // Check if the string has extra quotes
+ if len(innerVal) > 2 && strings.HasPrefix(innerVal, "\"") && strings.HasSuffix(innerVal, "\"") {
+ // Remove the extra quotes
+ v[key] = strings.Trim(innerVal, "\"")
+ }
+ }
+ case map[string]interface{}, []interface{}:
+ // Recursively process nested structures
+ sanitizeConfigRecursive(innerVal, durationKeys)
+ }
+ }
+ case []interface{}:
+ // Process each item in the array
+ for i, item := range v {
+ switch innerVal := item.(type) {
+ case map[string]interface{}, []interface{}:
+ // Recursively process nested structures
+ sanitizeConfigRecursive(innerVal, durationKeys)
+ case string:
+ // Check if string has unnecessary quotes
+ if len(innerVal) > 2 && strings.HasPrefix(innerVal, "\"") && strings.HasSuffix(innerVal, "\"") {
+ v[i] = strings.Trim(innerVal, "\"")
+ }
+ }
+ }
+ }
+}
// deleteMiddleware deletes a middleware configuration
func (s *Server) deleteMiddleware(c *gin.Context) {
id := c.Param("id")
@@ -253,15 +428,19 @@ func (s *Server) deleteMiddleware(c *gin.Context) {
}
// If something goes wrong, rollback
+ var txErr error
defer func() {
- if err != nil {
+ if txErr != nil {
tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
- result, err := tx.Exec("DELETE FROM middlewares WHERE id = ?", id)
- if err != nil {
- log.Printf("Error deleting middleware: %v", err)
+ log.Printf("Attempting to delete middleware %s", id)
+
+ result, txErr := tx.Exec("DELETE FROM middlewares WHERE id = ?", id)
+ if txErr != nil {
+ log.Printf("Error deleting middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to delete middleware")
return
}
@@ -278,13 +457,16 @@ func (s *Server) deleteMiddleware(c *gin.Context) {
return
}
+ log.Printf("Delete affected %d rows", rowsAffected)
+
// Commit the transaction
- if err = tx.Commit(); err != nil {
- log.Printf("Error committing transaction: %v", err)
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
+ log.Printf("Successfully deleted middleware %s", id)
c.JSON(http.StatusOK, gin.H{"message": "Middleware deleted successfully"})
}
@@ -356,24 +538,28 @@ func (s *Server) deleteResource(c *gin.Context) {
}
// If something goes wrong, rollback
+ var txErr error
defer func() {
- if err != nil {
+ if txErr != nil {
tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
// First delete any middleware relationships
- _, err = tx.Exec("DELETE FROM resource_middlewares WHERE resource_id = ?", id)
- if err != nil {
- log.Printf("Error removing resource middlewares: %v", err)
+ log.Printf("Removing middleware relationships for resource %s", id)
+ _, txErr = tx.Exec("DELETE FROM resource_middlewares WHERE resource_id = ?", id)
+ if txErr != nil {
+ log.Printf("Error removing resource middlewares: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to delete resource")
return
}
// Then delete the resource
- result, err := tx.Exec("DELETE FROM resources WHERE id = ?", id)
- if err != nil {
- log.Printf("Error deleting resource: %v", err)
+ log.Printf("Deleting resource %s", id)
+ result, txErr := tx.Exec("DELETE FROM resources WHERE id = ?", id)
+ if txErr != nil {
+ log.Printf("Error deleting resource: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to delete resource")
return
}
@@ -390,13 +576,16 @@ func (s *Server) deleteResource(c *gin.Context) {
return
}
+ log.Printf("Delete affected %d rows", rowsAffected)
+
// Commit the transaction
- if err = tx.Commit(); err != nil {
- log.Printf("Error committing transaction: %v", err)
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
+ log.Printf("Successfully deleted resource %s", id)
c.JSON(http.StatusOK, gin.H{"message": "Resource deleted successfully"})
}
@@ -462,41 +651,54 @@ func (s *Server) assignMiddleware(c *gin.Context) {
}
// If something goes wrong, rollback
+ var txErr error
defer func() {
- if err != nil {
+ if txErr != nil {
tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
// First delete any existing relationship
- _, err = tx.Exec(
+ log.Printf("Removing existing middleware relationship: resource=%s, middleware=%s",
+ resourceID, input.MiddlewareID)
+ _, txErr = tx.Exec(
"DELETE FROM resource_middlewares WHERE resource_id = ? AND middleware_id = ?",
resourceID, input.MiddlewareID,
)
- if err != nil {
- log.Printf("Error removing existing relationship: %v", err)
+ if txErr != nil {
+ log.Printf("Error removing existing relationship: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Then insert the new relationship
- _, err = tx.Exec(
+ log.Printf("Creating new middleware relationship: resource=%s, middleware=%s, priority=%d",
+ resourceID, input.MiddlewareID, input.Priority)
+ result, txErr := tx.Exec(
"INSERT INTO resource_middlewares (resource_id, middleware_id, priority) VALUES (?, ?, ?)",
resourceID, input.MiddlewareID, input.Priority,
)
- if err != nil {
- log.Printf("Error assigning middleware: %v", err)
+ if txErr != nil {
+ log.Printf("Error assigning middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to assign middleware")
return
}
+ rowsAffected, err := result.RowsAffected()
+ if err == nil {
+ log.Printf("Insert affected %d rows", rowsAffected)
+ }
+
// Commit the transaction
- if err = tx.Commit(); err != nil {
- log.Printf("Error committing transaction: %v", err)
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
+ log.Printf("Successfully assigned middleware %s to resource %s with priority %d",
+ input.MiddlewareID, resourceID, input.Priority)
c.JSON(http.StatusOK, gin.H{
"resource_id": resourceID,
"middleware_id": input.MiddlewareID,
@@ -552,14 +754,18 @@ func (s *Server) assignMultipleMiddlewares(c *gin.Context) {
}
// If something goes wrong, rollback
+ var txErr error
defer func() {
- if err != nil {
+ if txErr != nil {
tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
// Process each middleware
successful := make([]map[string]interface{}, 0)
+ log.Printf("Assigning %d middlewares to resource %s", len(input.Middlewares), resourceID)
+
for _, mw := range input.Middlewares {
// Default priority is 100 if not specified
if mw.Priority <= 0 {
@@ -580,40 +786,52 @@ func (s *Server) assignMultipleMiddlewares(c *gin.Context) {
}
// First delete any existing relationship
- _, err = tx.Exec(
+ log.Printf("Removing existing relationship: resource=%s, middleware=%s",
+ resourceID, mw.MiddlewareID)
+ _, txErr = tx.Exec(
"DELETE FROM resource_middlewares WHERE resource_id = ? AND middleware_id = ?",
resourceID, mw.MiddlewareID,
)
- if err != nil {
- log.Printf("Error removing existing relationship: %v", err)
+ if txErr != nil {
+ log.Printf("Error removing existing relationship: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Then insert the new relationship
- _, err = tx.Exec(
+ log.Printf("Creating new relationship: resource=%s, middleware=%s, priority=%d",
+ resourceID, mw.MiddlewareID, mw.Priority)
+ result, txErr := tx.Exec(
"INSERT INTO resource_middlewares (resource_id, middleware_id, priority) VALUES (?, ?, ?)",
resourceID, mw.MiddlewareID, mw.Priority,
)
- if err != nil {
- log.Printf("Error assigning middleware: %v", err)
+ if txErr != nil {
+ log.Printf("Error assigning middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to assign middleware")
return
}
-
- successful = append(successful, map[string]interface{}{
- "middleware_id": mw.MiddlewareID,
- "priority": mw.Priority,
- })
+
+ rowsAffected, err := result.RowsAffected()
+ if err == nil && rowsAffected > 0 {
+ log.Printf("Successfully assigned middleware %s with priority %d",
+ mw.MiddlewareID, mw.Priority)
+ successful = append(successful, map[string]interface{}{
+ "middleware_id": mw.MiddlewareID,
+ "priority": mw.Priority,
+ })
+ } else {
+ log.Printf("Warning: Insertion query succeeded but affected %d rows", rowsAffected)
+ }
}
// Commit the transaction
- if err = tx.Commit(); err != nil {
- log.Printf("Error committing transaction: %v", err)
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
+ log.Printf("Successfully assigned %d middlewares to resource %s", len(successful), resourceID)
c.JSON(http.StatusOK, gin.H{
"resource_id": resourceID,
"middlewares": successful,
@@ -630,6 +848,8 @@ func (s *Server) removeMiddleware(c *gin.Context) {
return
}
+ log.Printf("Removing middleware %s from resource %s", middlewareID, resourceID)
+
// Delete the relationship using a transaction
tx, err := s.db.Begin()
if err != nil {
@@ -639,19 +859,21 @@ func (s *Server) removeMiddleware(c *gin.Context) {
}
// If something goes wrong, rollback
+ var txErr error
defer func() {
- if err != nil {
+ if txErr != nil {
tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
- result, err := tx.Exec(
+ result, txErr := tx.Exec(
"DELETE FROM resource_middlewares WHERE resource_id = ? AND middleware_id = ?",
resourceID, middlewareID,
)
- if err != nil {
- log.Printf("Error removing middleware: %v", err)
+ if txErr != nil {
+ log.Printf("Error removing middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to remove middleware")
return
}
@@ -664,20 +886,420 @@ func (s *Server) removeMiddleware(c *gin.Context) {
}
if rowsAffected == 0 {
+ log.Printf("No relationship found between resource %s and middleware %s", resourceID, middlewareID)
ResponseWithError(c, http.StatusNotFound, "Resource middleware relationship not found")
return
}
+ log.Printf("Delete affected %d rows", rowsAffected)
+
// Commit the transaction
- if err = tx.Commit(); err != nil {
- log.Printf("Error committing transaction: %v", err)
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
+ log.Printf("Successfully removed middleware %s from resource %s", middlewareID, resourceID)
c.JSON(http.StatusOK, gin.H{"message": "Middleware removed from resource successfully"})
}
+// updateHTTPConfig updates the HTTP router entrypoints configuration
+func (s *Server) updateHTTPConfig(c *gin.Context) {
+ id := c.Param("id")
+ if id == "" {
+ ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
+ return
+ }
+
+ var input struct {
+ Entrypoints string `json:"entrypoints"`
+ }
+
+ if err := c.ShouldBindJSON(&input); err != nil {
+ ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
+ return
+ }
+
+ // Verify resource exists and is active
+ var exists int
+ var status string
+ err := s.db.QueryRow("SELECT 1, status FROM resources WHERE id = ?", id).Scan(&exists, &status)
+ if err == sql.ErrNoRows {
+ ResponseWithError(c, http.StatusNotFound, "Resource not found")
+ return
+ } else if err != nil {
+ log.Printf("Error checking resource existence: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ // Don't allow updating disabled resources
+ if status == "disabled" {
+ ResponseWithError(c, http.StatusBadRequest, "Cannot update a disabled resource")
+ return
+ }
+
+ // Validate entrypoints - should be comma-separated list
+ if input.Entrypoints == "" {
+ input.Entrypoints = "websecure" // Default
+ }
+
+ // Update the resource within a transaction
+ tx, err := s.db.Begin()
+ if err != nil {
+ log.Printf("Error beginning transaction: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ var txErr error
+ defer func() {
+ if txErr != nil {
+ tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
+ }
+ }()
+
+ log.Printf("Updating HTTP entrypoints for resource %s: %s", id, input.Entrypoints)
+
+ result, txErr := tx.Exec(
+ "UPDATE resources SET entrypoints = ?, updated_at = ? WHERE id = ?",
+ input.Entrypoints, time.Now(), id,
+ )
+
+ if txErr != nil {
+ log.Printf("Error updating resource entrypoints: %v", txErr)
+ ResponseWithError(c, http.StatusInternalServerError, "Failed to update resource")
+ return
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err == nil {
+ log.Printf("Update affected %d rows", rowsAffected)
+ if rowsAffected == 0 {
+ log.Printf("Warning: Update query succeeded but no rows were affected")
+ }
+ }
+
+ // Commit the transaction
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ log.Printf("Successfully updated HTTP entrypoints for resource %s", id)
+ c.JSON(http.StatusOK, gin.H{
+ "id": id,
+ "entrypoints": input.Entrypoints,
+ })
+}
+// updateTLSConfig updates the TLS certificate domains configuration
+func (s *Server) updateTLSConfig(c *gin.Context) {
+ id := c.Param("id")
+ if id == "" {
+ ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
+ return
+ }
+
+ var input struct {
+ TLSDomains string `json:"tls_domains"`
+ }
+
+ if err := c.ShouldBindJSON(&input); err != nil {
+ ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
+ return
+ }
+
+ // Verify resource exists and is active
+ var exists int
+ var status string
+ err := s.db.QueryRow("SELECT 1, status FROM resources WHERE id = ?", id).Scan(&exists, &status)
+ if err == sql.ErrNoRows {
+ ResponseWithError(c, http.StatusNotFound, "Resource not found")
+ return
+ } else if err != nil {
+ log.Printf("Error checking resource existence: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ // Don't allow updating disabled resources
+ if status == "disabled" {
+ ResponseWithError(c, http.StatusBadRequest, "Cannot update a disabled resource")
+ return
+ }
+
+ // Update the resource within a transaction
+ tx, err := s.db.Begin()
+ if err != nil {
+ log.Printf("Error beginning transaction: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ var txErr error
+ defer func() {
+ if txErr != nil {
+ tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
+ }
+ }()
+
+ log.Printf("Updating TLS domains for resource %s: %s", id, input.TLSDomains)
+
+ result, txErr := tx.Exec(
+ "UPDATE resources SET tls_domains = ?, updated_at = ? WHERE id = ?",
+ input.TLSDomains, time.Now(), id,
+ )
+
+ if txErr != nil {
+ log.Printf("Error updating TLS domains: %v", txErr)
+ ResponseWithError(c, http.StatusInternalServerError, "Failed to update TLS domains")
+ return
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err == nil {
+ log.Printf("Update affected %d rows", rowsAffected)
+ if rowsAffected == 0 {
+ log.Printf("Warning: Update query succeeded but no rows were affected")
+ }
+ }
+
+ // Commit the transaction
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ log.Printf("Successfully updated TLS domains for resource %s", id)
+ c.JSON(http.StatusOK, gin.H{
+ "id": id,
+ "tls_domains": input.TLSDomains,
+ })
+}
+
+// updateTCPConfig updates the TCP SNI router configuration
+func (s *Server) updateTCPConfig(c *gin.Context) {
+ id := c.Param("id")
+ if id == "" {
+ ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
+ return
+ }
+
+ var input struct {
+ TCPEnabled bool `json:"tcp_enabled"`
+ TCPEntrypoints string `json:"tcp_entrypoints"`
+ TCPSNIRule string `json:"tcp_sni_rule"`
+ }
+
+ if err := c.ShouldBindJSON(&input); err != nil {
+ ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
+ return
+ }
+
+ // Verify resource exists and is active
+ var exists int
+ var status string
+ err := s.db.QueryRow("SELECT 1, status FROM resources WHERE id = ?", id).Scan(&exists, &status)
+ if err == sql.ErrNoRows {
+ ResponseWithError(c, http.StatusNotFound, "Resource not found")
+ return
+ } else if err != nil {
+ log.Printf("Error checking resource existence: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ // Don't allow updating disabled resources
+ if status == "disabled" {
+ ResponseWithError(c, http.StatusBadRequest, "Cannot update a disabled resource")
+ return
+ }
+
+ // Validate TCP entrypoints if provided
+ if input.TCPEntrypoints == "" {
+ input.TCPEntrypoints = "tcp" // Default
+ }
+
+ // Validate SNI rule if provided
+ if input.TCPSNIRule != "" {
+ // Basic validation - ensure it contains HostSNI
+ if !strings.Contains(input.TCPSNIRule, "HostSNI") {
+ ResponseWithError(c, http.StatusBadRequest, "TCP SNI rule must contain HostSNI matcher")
+ return
+ }
+ }
+
+ // Convert boolean to integer for SQLite
+ tcpEnabled := 0
+ if input.TCPEnabled {
+ tcpEnabled = 1
+ }
+
+ // Update the resource within a transaction
+ tx, err := s.db.Begin()
+ if err != nil {
+ log.Printf("Error beginning transaction: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ var txErr error
+ defer func() {
+ if txErr != nil {
+ tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
+ }
+ }()
+
+ log.Printf("Updating TCP config for resource %s: enabled=%t, entrypoints=%s",
+ id, input.TCPEnabled, input.TCPEntrypoints)
+
+ result, txErr := tx.Exec(
+ "UPDATE resources SET tcp_enabled = ?, tcp_entrypoints = ?, tcp_sni_rule = ?, updated_at = ? WHERE id = ?",
+ tcpEnabled, input.TCPEntrypoints, input.TCPSNIRule, time.Now(), id,
+ )
+
+ if txErr != nil {
+ log.Printf("Error updating TCP config: %v", txErr)
+ ResponseWithError(c, http.StatusInternalServerError, "Failed to update TCP configuration")
+ return
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err == nil {
+ log.Printf("Update affected %d rows", rowsAffected)
+ if rowsAffected == 0 {
+ log.Printf("Warning: Update query succeeded but no rows were affected")
+ }
+ }
+
+ // Commit the transaction
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ log.Printf("Successfully updated TCP configuration for resource %s", id)
+ c.JSON(http.StatusOK, gin.H{
+ "id": id,
+ "tcp_enabled": input.TCPEnabled,
+ "tcp_entrypoints": input.TCPEntrypoints,
+ "tcp_sni_rule": input.TCPSNIRule,
+ })
+}
+
+// updateHeadersConfig updates the custom headers configuration
+func (s *Server) updateHeadersConfig(c *gin.Context) {
+ id := c.Param("id")
+ if id == "" {
+ ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
+ return
+ }
+
+ var input struct {
+ CustomHeaders map[string]string `json:"custom_headers" binding:"required"`
+ }
+
+ if err := c.ShouldBindJSON(&input); err != nil {
+ ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
+ return
+ }
+
+ // Verify resource exists and is active
+ var exists int
+ var status string
+ err := s.db.QueryRow("SELECT 1, status FROM resources WHERE id = ?", id).Scan(&exists, &status)
+ if err == sql.ErrNoRows {
+ ResponseWithError(c, http.StatusNotFound, "Resource not found")
+ return
+ } else if err != nil {
+ log.Printf("Error checking resource existence: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ // Don't allow updating disabled resources
+ if status == "disabled" {
+ ResponseWithError(c, http.StatusBadRequest, "Cannot update a disabled resource")
+ return
+ }
+
+ // Convert headers to JSON for storage
+ headersJSON, err := json.Marshal(input.CustomHeaders)
+ if err != nil {
+ log.Printf("Error encoding headers: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Failed to encode headers")
+ return
+ }
+
+ // Update the resource within a transaction
+ tx, err := s.db.Begin()
+ if err != nil {
+ log.Printf("Error beginning transaction: %v", err)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ var txErr error
+ defer func() {
+ if txErr != nil {
+ tx.Rollback()
+ log.Printf("Transaction rolled back due to error: %v", txErr)
+ }
+ }()
+
+ log.Printf("Updating custom headers for resource %s with %d headers",
+ id, len(input.CustomHeaders))
+
+ result, txErr := tx.Exec(
+ "UPDATE resources SET custom_headers = ?, updated_at = ? WHERE id = ?",
+ string(headersJSON), time.Now(), id,
+ )
+
+ if txErr != nil {
+ log.Printf("Error updating custom headers: %v", txErr)
+ ResponseWithError(c, http.StatusInternalServerError, "Failed to update custom headers")
+ return
+ }
+
+ rowsAffected, err := result.RowsAffected()
+ if err == nil {
+ log.Printf("Update affected %d rows", rowsAffected)
+ if rowsAffected == 0 {
+ log.Printf("Warning: Update query succeeded but no rows were affected")
+ }
+ }
+
+ // Commit the transaction
+ if txErr = tx.Commit(); txErr != nil {
+ log.Printf("Error committing transaction: %v", txErr)
+ ResponseWithError(c, http.StatusInternalServerError, "Database error")
+ return
+ }
+
+ // Verify the update by reading back the custom_headers
+ var storedHeaders string
+ verifyErr := s.db.QueryRow("SELECT custom_headers FROM resources WHERE id = ?", id).Scan(&storedHeaders)
+ if verifyErr != nil {
+ log.Printf("Warning: Could not verify headers update: %v", verifyErr)
+ } else if storedHeaders == "" {
+ log.Printf("Warning: Headers may be empty after update for resource %s", id)
+ } else {
+ log.Printf("Successfully verified headers update for resource %s", id)
+ }
+
+ log.Printf("Successfully updated custom headers for resource %s", id)
+ c.JSON(http.StatusOK, gin.H{
+ "id": id,
+ "custom_headers": input.CustomHeaders,
+ })
+}
// generateID generates a random 16-character hex string
func generateID() (string, error) {
bytes := make([]byte, 8)
diff --git a/api/routes.go b/api/routes.go
index 67f658112..449cb2a1c 100644
--- a/api/routes.go
+++ b/api/routes.go
@@ -116,8 +116,15 @@ func (s *Server) setupRoutes(uiPath string) {
resources.GET("/:id", s.getResource)
resources.DELETE("/:id", s.deleteResource)
resources.POST("/:id/middlewares", s.assignMiddleware)
- resources.POST("/:id/middlewares/bulk", s.assignMultipleMiddlewares) // New endpoint for bulk assignment
+ resources.POST("/:id/middlewares/bulk", s.assignMultipleMiddlewares)
resources.DELETE("/:id/middlewares/:middlewareId", s.removeMiddleware)
+
+ // Router configuration routes
+ resources.PUT("/:id/config/http", s.updateHTTPConfig) // HTTP entrypoints
+ resources.PUT("/:id/config/tls", s.updateTLSConfig) // TLS certificate domains
+ resources.PUT("/:id/config/tcp", s.updateTCPConfig) // TCP SNI routing
+ resources.PUT("/:id/config/headers", s.updateHeadersConfig) // Custom Host headers
+ resources.PUT("/:id/config/priority", s.updateRouterPriority) // Router priority
}
}
diff --git a/config/defaults.go b/config/defaults.go
index 9dc90b701..2e5bee56d 100644
--- a/config/defaults.go
+++ b/config/defaults.go
@@ -6,6 +6,9 @@ import (
"log"
"os"
"path/filepath"
+ "strconv"
+ "strings"
+ "fmt"
"github.com/hhftechnology/middleware-manager/database"
"gopkg.in/yaml.v3"
@@ -64,6 +67,26 @@ func LoadDefaultTemplates(db *database.DB) error {
return err
}
+ // Process templates to ensure proper value preservation based on middleware type
+ for i := range templates.Middlewares {
+ // Apply middleware-specific processing based on type
+ switch templates.Middlewares[i].Type {
+ case "headers":
+ processHeadersMiddleware(&templates.Middlewares[i].Config)
+ case "redirectRegex", "redirectScheme", "replacePath", "replacePathRegex", "stripPrefix", "stripPrefixRegex":
+ processPathMiddleware(&templates.Middlewares[i].Config, templates.Middlewares[i].Type)
+ case "basicAuth", "digestAuth", "forwardAuth":
+ processAuthMiddleware(&templates.Middlewares[i].Config, templates.Middlewares[i].Type)
+ case "plugin":
+ processPluginMiddleware(&templates.Middlewares[i].Config)
+ case "chain":
+ processChainingMiddleware(&templates.Middlewares[i].Config)
+ default:
+ // General processing for other middleware types
+ templates.Middlewares[i].Config = preserveTraefikValues(templates.Middlewares[i].Config).(map[string]interface{})
+ }
+ }
+
// Add templates to the database if they don't exist
for _, middleware := range templates.Middlewares {
// Check if the middleware already exists
@@ -98,6 +121,366 @@ func LoadDefaultTemplates(db *database.DB) error {
return nil
}
+// preserveTraefikValues ensures all values in Traefik configurations are properly handled
+// This handles special cases in different middleware types and ensures precise value preservation
+func preserveTraefikValues(data interface{}) interface{} {
+ if data == nil {
+ return nil
+ }
+
+ switch v := data.(type) {
+ case map[string]interface{}:
+ // Process each key-value pair in the map
+ for key, val := range v {
+ // Process values based on key names that might need special handling
+ switch {
+ // URL or path related fields
+ case key == "path" || key == "url" || key == "address" || strings.HasSuffix(key, "Path"):
+ // Ensure path strings keep their exact format
+ if strVal, ok := val.(string); ok && strVal != "" {
+ // Keep exact string formatting
+ v[key] = strVal
+ } else {
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // Regex and replacement patterns
+ case key == "regex" || key == "replacement" || strings.HasSuffix(key, "Regex"):
+ // Ensure regex patterns are preserved exactly
+ if strVal, ok := val.(string); ok && strVal != "" {
+ // Keep exact string formatting with special characters
+ v[key] = strVal
+ } else {
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // API keys and security tokens
+ case key == "key" || key == "token" || key == "secret" ||
+ strings.Contains(key, "Key") || strings.Contains(key, "Token") ||
+ strings.Contains(key, "Secret") || strings.Contains(key, "Password"):
+ // Ensure API keys and tokens are preserved exactly
+ if strVal, ok := val.(string); ok {
+ // Always preserve keys/tokens exactly as-is, even if empty
+ v[key] = strVal
+ } else {
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // Empty header values (common in security headers middleware)
+ case key == "Server" || key == "X-Powered-By" || strings.HasPrefix(key, "X-"):
+ // Empty string values are often used to remove headers
+ if strVal, ok := val.(string); ok {
+ // Preserve empty strings exactly
+ v[key] = strVal
+ } else {
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // IP addresses and networks
+ case key == "ip" || key == "clientIP" || strings.Contains(key, "IP") ||
+ key == "sourceRange" || key == "excludedIPs":
+ // IP addresses often need exact formatting
+ v[key] = preserveTraefikValues(val)
+
+ // Boolean flags that control behavior
+ case strings.HasPrefix(key, "is") || strings.HasPrefix(key, "has") ||
+ strings.HasPrefix(key, "enable") || strings.HasSuffix(key, "enabled") ||
+ strings.HasSuffix(key, "Enabled") || key == "permanent" || key == "forceSlash":
+ // Ensure boolean values are preserved as actual booleans
+ if boolVal, ok := val.(bool); ok {
+ v[key] = boolVal
+ } else if strVal, ok := val.(string); ok {
+ // Convert string "true"/"false" to actual boolean if needed
+ if strVal == "true" {
+ v[key] = true
+ } else if strVal == "false" {
+ v[key] = false
+ } else {
+ v[key] = strVal // Keep as is if not a boolean string
+ }
+ } else {
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // Integer values like timeouts, ports, limits
+ case key == "amount" || key == "burst" || key == "port" ||
+ strings.HasSuffix(key, "Seconds") || strings.HasSuffix(key, "Limit") ||
+ strings.HasSuffix(key, "Timeout") || strings.HasSuffix(key, "Size") ||
+ key == "depth" || key == "priority" || key == "statusCode" ||
+ key == "attempts" || key == "responseCode":
+ // Ensure numeric values are preserved as numbers
+ switch numVal := val.(type) {
+ case int:
+ v[key] = numVal
+ case float64:
+ // Keep as float if it has decimal part, otherwise convert to int
+ if numVal == float64(int(numVal)) {
+ v[key] = int(numVal)
+ } else {
+ v[key] = numVal
+ }
+ case string:
+ // Try to convert string to number if it looks like one
+ if i, err := strconv.Atoi(numVal); err == nil {
+ v[key] = i
+ } else if f, err := strconv.ParseFloat(numVal, 64); err == nil {
+ v[key] = f
+ } else {
+ v[key] = numVal // Keep as string if not numeric
+ }
+ default:
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // Default handling for other keys
+ default:
+ v[key] = preserveTraefikValues(val)
+ }
+ }
+ return v
+
+ case []interface{}:
+ // Process each element in the array
+ for i, item := range v {
+ v[i] = preserveTraefikValues(item)
+ }
+ return v
+
+ case string:
+ // Preserve all string values exactly as they are
+ return v
+
+ case int, float64, bool:
+ // Preserve primitive types as they are
+ return v
+
+ default:
+ // For any other type, return as is
+ return v
+ }
+}
+
+// processHeadersMiddleware handles the headers middleware special processing
+func processHeadersMiddleware(config *map[string]interface{}) {
+ // Special handling for response headers which might contain empty strings
+ if customResponseHeaders, ok := (*config)["customResponseHeaders"].(map[string]interface{}); ok {
+ for key, value := range customResponseHeaders {
+ // Ensure empty strings are preserved exactly
+ if strValue, ok := value.(string); ok {
+ customResponseHeaders[key] = strValue
+ }
+ }
+ }
+
+ // Special handling for request headers which might contain empty strings
+ if customRequestHeaders, ok := (*config)["customRequestHeaders"].(map[string]interface{}); ok {
+ for key, value := range customRequestHeaders {
+ // Ensure empty strings are preserved exactly
+ if strValue, ok := value.(string); ok {
+ customRequestHeaders[key] = strValue
+ }
+ }
+ }
+
+ // Process header fields that are often quoted strings
+ specialStringFields := []string{
+ "customFrameOptionsValue", "contentSecurityPolicy",
+ "referrerPolicy", "permissionsPolicy",
+ }
+
+ for _, field := range specialStringFields {
+ if value, ok := (*config)[field].(string); ok {
+ // Preserve string exactly, especially if it contains quotes
+ (*config)[field] = value
+ }
+ }
+
+ // Process other header configuration values
+ *config = preserveTraefikValues(*config).(map[string]interface{})
+}
+
+// processChainingMiddleware handles chain middleware special processing
+func processChainingMiddleware(config *map[string]interface{}) {
+ if middlewares, ok := (*config)["middlewares"].([]interface{}); ok {
+ for i, middleware := range middlewares {
+ if middlewareStr, ok := middleware.(string); ok {
+ // If this is not already a fully qualified middleware reference
+ if !strings.Contains(middlewareStr, "@") {
+ // Assume it's from our file provider
+ middlewares[i] = fmt.Sprintf("%s@file", middlewareStr)
+ }
+ }
+ }
+ (*config)["middlewares"] = middlewares
+ }
+
+ // Process other chain configuration values
+ *config = preserveTraefikValues(*config).(map[string]interface{})
+}
+
+// processPathMiddleware handles path manipulation middlewares
+func processPathMiddleware(config *map[string]interface{}, middlewareType string) {
+ // Special handling for regex patterns - these need exact preservation
+ if regex, ok := (*config)["regex"].(string); ok {
+ // Preserve regex pattern exactly
+ (*config)["regex"] = regex
+ } else if regexList, ok := (*config)["regex"].([]interface{}); ok {
+ // Handle regex arrays (like in stripPrefixRegex)
+ for i, r := range regexList {
+ if regexStr, ok := r.(string); ok {
+ regexList[i] = regexStr
+ }
+ }
+ }
+
+ // Special handling for replacement patterns
+ if replacement, ok := (*config)["replacement"].(string); ok {
+ // Preserve replacement pattern exactly
+ (*config)["replacement"] = replacement
+ }
+
+ // Special handling for path values
+ if path, ok := (*config)["path"].(string); ok {
+ // Preserve path exactly
+ (*config)["path"] = path
+ }
+
+ // Special handling for prefixes arrays
+ if prefixes, ok := (*config)["prefixes"].([]interface{}); ok {
+ for i, prefix := range prefixes {
+ if prefixStr, ok := prefix.(string); ok {
+ prefixes[i] = prefixStr
+ }
+ }
+ }
+
+ // Special handling for scheme
+ if scheme, ok := (*config)["scheme"].(string); ok {
+ // Preserve scheme exactly
+ (*config)["scheme"] = scheme
+ }
+
+ // Process boolean options
+ if forceSlash, ok := (*config)["forceSlash"].(bool); ok {
+ (*config)["forceSlash"] = forceSlash
+ }
+
+ if permanent, ok := (*config)["permanent"].(bool); ok {
+ (*config)["permanent"] = permanent
+ }
+
+ // Process other path manipulation configuration values
+ *config = preserveTraefikValues(*config).(map[string]interface{})
+}
+
+// processAuthMiddleware handles authentication middleware special processing
+func processAuthMiddleware(config *map[string]interface{}, middlewareType string) {
+ // ForwardAuth middleware special handling
+ if middlewareType == "forwardAuth" {
+ if address, ok := (*config)["address"].(string); ok {
+ // Preserve address URL exactly
+ (*config)["address"] = address
+ }
+
+ // Process trust settings
+ if trustForward, ok := (*config)["trustForwardHeader"].(bool); ok {
+ (*config)["trustForwardHeader"] = trustForward
+ }
+
+ // Process headers array
+ if headers, ok := (*config)["authResponseHeaders"].([]interface{}); ok {
+ for i, header := range headers {
+ if headerStr, ok := header.(string); ok {
+ headers[i] = headerStr
+ }
+ }
+ }
+ }
+
+ // BasicAuth/DigestAuth middleware special handling
+ if middlewareType == "basicAuth" || middlewareType == "digestAuth" {
+ // Preserve exact format of users array
+ if users, ok := (*config)["users"].([]interface{}); ok {
+ for i, user := range users {
+ if userStr, ok := user.(string); ok {
+ users[i] = userStr
+ }
+ }
+ }
+ }
+
+ // Process other auth configuration values
+ *config = preserveTraefikValues(*config).(map[string]interface{})
+}
+
+// processPluginMiddleware handles plugin middleware special processing
+func processPluginMiddleware(config *map[string]interface{}) {
+ // Process plugins (including CrowdSec)
+ for _, pluginCfg := range *config {
+ if pluginConfig, ok := pluginCfg.(map[string]interface{}); ok {
+ // Process special fields in plugin configurations
+
+ // Process API keys and secrets - must be preserved exactly
+ keyFields := []string{
+ "crowdsecLapiKey", "apiKey", "token", "secret", "password",
+ "key", "accessKey", "secretKey", "captchaSiteKey", "captchaSecretKey",
+ }
+
+ for _, field := range keyFields {
+ if val, exists := pluginConfig[field]; exists {
+ if strVal, ok := val.(string); ok {
+ // Ensure keys are preserved exactly as-is
+ pluginConfig[field] = strVal
+ }
+ }
+ }
+
+ // Process boolean options
+ boolFields := []string{
+ "enabled", "failureBlock", "unreachableBlock", "insecureVerify",
+ "allowLocalRequests", "logLocalRequests", "logAllowedRequests",
+ "logApiRequests", "silentStartUp", "forceMonthlyUpdate",
+ "allowUnknownCountries", "blackListMode", "addCountryHeader",
+ }
+
+ for _, field := range boolFields {
+ for configKey, val := range pluginConfig {
+ if strings.Contains(configKey, field) {
+ if boolVal, ok := val.(bool); ok {
+ pluginConfig[configKey] = boolVal
+ }
+ }
+ }
+ }
+
+ // Process arrays
+ arrayFields := []string{
+ "forwardedHeadersTrustedIPs", "clientTrustedIPs", "countries",
+ }
+
+ for _, field := range arrayFields {
+ for configKey, val := range pluginConfig {
+ if strings.Contains(configKey, field) {
+ if arrayVal, ok := val.([]interface{}); ok {
+ for i, item := range arrayVal {
+ if strItem, ok := item.(string); ok {
+ arrayVal[i] = strItem
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Process remaining fields
+ pluginConfig = preserveTraefikValues(pluginConfig).(map[string]interface{})
+ }
+ }
+
+ // Process the entire config
+ *config = preserveTraefikValues(*config).(map[string]interface{})
+}
+
// EnsureConfigDirectory ensures the configuration directory exists
func EnsureConfigDirectory(path string) error {
return os.MkdirAll(path, 0755)
@@ -116,12 +499,13 @@ func SaveTemplateFile(templatesDir string) error {
// Create default templates
templates := DefaultTemplates{
Middlewares: []DefaultMiddleware{
+ // Authentication middlewares
{
ID: "authelia",
Name: "Authelia",
Type: "forwardAuth",
Config: map[string]interface{}{
- "address": "http://authelia:9091/api/verify?rd=https://auth.yourdomain.com",
+ "address": "http://authelia:9091/api/authz/forward-auth",
"trustForwardHeader": true,
"authResponseHeaders": []string{
"Remote-User",
@@ -157,15 +541,461 @@ func SaveTemplateFile(templatesDir string) error {
},
},
},
+ {
+ ID: "digest-auth",
+ Name: "Digest Auth",
+ Type: "digestAuth",
+ Config: map[string]interface{}{
+ "users": []string{
+ "test:traefik:a2688e031edb4be6a3797f3882655c05",
+ },
+ },
+ },
+ {
+ ID: "jwt-auth",
+ Name: "JWT Authentication",
+ Type: "forwardAuth",
+ Config: map[string]interface{}{
+ "address": "http://jwt-auth:8080/verify",
+ "trustForwardHeader": true,
+ "authResponseHeaders": []string{
+ "X-JWT-Sub",
+ "X-JWT-Name",
+ "X-JWT-Email",
+ },
+ },
+ },
+
+ // Security middlewares
+ {
+ ID: "ip-whitelist",
+ Name: "IP Whitelist",
+ Type: "ipWhiteList",
+ Config: map[string]interface{}{
+ "sourceRange": []string{
+ "127.0.0.1/32",
+ "192.168.1.0/24",
+ "10.0.0.0/8",
+ },
+ },
+ },
+ {
+ ID: "ip-allowlist",
+ Name: "IP Allow List",
+ Type: "ipAllowList",
+ Config: map[string]interface{}{
+ "sourceRange": []string{
+ "127.0.0.1/32",
+ "192.168.1.0/24",
+ "10.0.0.0/8",
+ },
+ },
+ },
+ {
+ ID: "rate-limit",
+ Name: "Rate Limit",
+ Type: "rateLimit",
+ Config: map[string]interface{}{
+ "average": int(100),
+ "burst": int(50),
+ },
+ },
+ {
+ ID: "headers-standard",
+ Name: "Standard Security Headers",
+ Type: "headers",
+ Config: map[string]interface{}{
+ "accessControlAllowMethods": []string{
+ "GET",
+ "OPTIONS",
+ "PUT",
+ },
+ "browserXssFilter": true,
+ "contentTypeNosniff": true,
+ "customFrameOptionsValue": "SAMEORIGIN",
+ "customResponseHeaders": map[string]string{
+ "X-Forwarded-Proto": "https",
+ "X-Robots-Tag": "none,noarchive,nosnippet,notranslate,noimageindex",
+ "Server": "", // Empty string to remove header
+ },
+ "forceSTSHeader": true,
+ "hostsProxyHeaders": []string{
+ "X-Forwarded-Host",
+ },
+ "permissionsPolicy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), vr=()",
+ "referrerPolicy": "same-origin",
+ "sslProxyHeaders": map[string]string{
+ "X-Forwarded-Proto": "https",
+ },
+ "stsIncludeSubdomains": true,
+ "stsPreload": true,
+ "stsSeconds": int(63072000),
+ },
+ },
+ {
+ ID: "in-flight-req",
+ Name: "In-Flight Request Limiter",
+ Type: "inFlightReq",
+ Config: map[string]interface{}{
+ "amount": int(10),
+ "sourceCriterion": map[string]interface{}{
+ "ipStrategy": map[string]interface{}{
+ "depth": int(2),
+ "excludedIPs": []string{
+ "127.0.0.1/32",
+ },
+ },
+ },
+ },
+ },
+ {
+ ID: "pass-tls-cert",
+ Name: "Pass TLS Client Certificate",
+ Type: "passTLSClientCert",
+ Config: map[string]interface{}{
+ "pem": true,
+ },
+ },
+
+ // Path manipulation middlewares - with properly formatted regex patterns
+ {
+ ID: "add-prefix",
+ Name: "Add Prefix",
+ Type: "addPrefix",
+ Config: map[string]interface{}{
+ "prefix": "/api",
+ },
+ },
+ {
+ ID: "strip-prefix",
+ Name: "Strip Prefix",
+ Type: "stripPrefix",
+ Config: map[string]interface{}{
+ "prefixes": []string{
+ "/api",
+ "/v1",
+ },
+ "forceSlash": true,
+ },
+ },
+ {
+ ID: "strip-prefix-regex",
+ Name: "Strip Prefix Regex",
+ Type: "stripPrefixRegex",
+ Config: map[string]interface{}{
+ "regex": []string{
+ "/foo/[a-z0-9]+/[0-9]+/",
+ },
+ },
+ },
+ {
+ ID: "replace-path",
+ Name: "Replace Path",
+ Type: "replacePath",
+ Config: map[string]interface{}{
+ "path": "/api",
+ },
+ },
+ {
+ ID: "replace-path-regex",
+ Name: "Replace Path Regex",
+ Type: "replacePathRegex",
+ Config: map[string]interface{}{
+ "regex": "^/foo/(.*)",
+ "replacement": "/bar/$1",
+ },
+ },
+
+ // Redirect middlewares - with properly formatted regex patterns
+ {
+ ID: "redirect-regex",
+ Name: "Redirect Regex",
+ Type: "redirectRegex",
+ Config: map[string]interface{}{
+ "regex": "^http://localhost/(.*)",
+ "replacement": "https://example.com/${1}",
+ "permanent": true,
+ },
+ },
+ {
+ ID: "redirect-scheme",
+ Name: "Redirect to HTTPS",
+ Type: "redirectScheme",
+ Config: map[string]interface{}{
+ "scheme": "https",
+ "port": "443",
+ "permanent": true,
+ },
+ },
+
+ // Content processing middlewares
+ {
+ ID: "compress",
+ Name: "Compress Response",
+ Type: "compress",
+ Config: map[string]interface{}{
+ "excludedContentTypes": []string{
+ "text/event-stream",
+ },
+ "includedContentTypes": []string{
+ "text/html",
+ "text/plain",
+ "application/json",
+ },
+ "minResponseBodyBytes": int(1024),
+ "encodings": []string{
+ "gzip",
+ "br",
+ },
+ },
+ },
+ {
+ ID: "buffering",
+ Name: "Request/Response Buffering",
+ Type: "buffering",
+ Config: map[string]interface{}{
+ "maxRequestBodyBytes": int(5000000),
+ "memRequestBodyBytes": int(2000000),
+ "maxResponseBodyBytes": int(5000000),
+ "memResponseBodyBytes": int(2000000),
+ "retryExpression": "IsNetworkError() && Attempts() < 2",
+ },
+ },
+ {
+ ID: "content-type",
+ Name: "Content Type Auto-Detector",
+ Type: "contentType",
+ Config: map[string]interface{}{},
+ },
+
+ // Error handling and reliability middlewares
+ {
+ ID: "circuit-breaker",
+ Name: "Circuit Breaker",
+ Type: "circuitBreaker",
+ Config: map[string]interface{}{
+ "expression": "NetworkErrorRatio() > 0.20 || ResponseCodeRatio(500, 600, 0, 600) > 0.25",
+ "checkPeriod": "10s",
+ "fallbackDuration": "30s",
+ "recoveryDuration": "60s",
+ "responseCode": int(503),
+ },
+ },
+ {
+ ID: "retry",
+ Name: "Retry Failed Requests",
+ Type: "retry",
+ Config: map[string]interface{}{
+ "attempts": int(3),
+ "initialInterval": "100ms",
+ },
+ },
+ {
+ ID: "error-pages",
+ Name: "Custom Error Pages",
+ Type: "errors",
+ Config: map[string]interface{}{
+ "status": []string{
+ "500-599",
+ },
+ "service": "error-handler-service",
+ "query": "/{status}.html",
+ },
+ },
+ {
+ ID: "grpc-web",
+ Name: "gRPC Web",
+ Type: "grpcWeb",
+ Config: map[string]interface{}{
+ "allowOrigins": []string{
+ "*",
+ },
+ },
+ },
+
+ // Chain middlewares
+ {
+ ID: "security-chain",
+ Name: "Security Chain",
+ Type: "chain",
+ Config: map[string]interface{}{
+ "middlewares": []string{
+ "rate-limit",
+ "ip-whitelist",
+ },
+ },
+ },
+
+ // Crowdsec plugin middleware with proper API key handling
+ {
+ ID: "crowdsec",
+ Name: "Crowdsec",
+ Type: "plugin",
+ Config: map[string]interface{}{
+ "crowdsec": map[string]interface{}{
+ "enabled": true,
+ "logLevel": "INFO",
+ "updateIntervalSeconds": int(15),
+ "updateMaxFailure": int(0),
+ "defaultDecisionSeconds": int(15),
+ "httpTimeoutSeconds": int(10),
+ "crowdsecMode": "live",
+ "crowdsecAppsecEnabled": true,
+ "crowdsecAppsecHost": "crowdsec:7422",
+ "crowdsecAppsecFailureBlock": true,
+ "crowdsecAppsecUnreachableBlock": true,
+ "crowdsecAppsecBodyLimit": int(10485760), // Use int instead of integer to avoid scientific notation
+ "crowdsecLapiKey": "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK",
+ "crowdsecLapiHost": "crowdsec:8080",
+ "crowdsecLapiScheme": "http",
+ "forwardedHeadersTrustedIPs": []string{
+ "0.0.0.0/0",
+ },
+ "clientTrustedIPs": []string{
+ "10.0.0.0/8",
+ "172.16.0.0/12",
+ "192.168.0.0/16",
+ },
+ },
+ },
+ },
+
+ // Special use case middlewares - with properly formatted regex pattern
+ {
+ ID: "nextcloud-dav",
+ Name: "Nextcloud WebDAV Redirect",
+ Type: "replacePathRegex",
+ Config: map[string]interface{}{
+ "regex": "^/.well-known/ca(l|rd)dav",
+ "replacement": "/remote.php/dav/",
+ },
+ },
+
+ // Custom headers example with empty string preservation
+ {
+ ID: "custom-headers-example",
+ Name: "Custom Headers Example",
+ Type: "headers",
+ Config: map[string]interface{}{
+ "customRequestHeaders": map[string]string{
+ "X-Script-Name": "test",
+ "X-Custom-Value": "value with spaces",
+ "X-Custom-Request-Header": "", // Empty string to remove header
+ },
+ "customResponseHeaders": map[string]string{
+ "X-Custom-Response-Header": "value",
+ "Server": "", // Empty string to remove header
+ },
+ },
+ },
},
}
- // Convert to YAML
- data, err := yaml.Marshal(templates)
+ // Process all templates to ensure proper value preservation
+ for i := range templates.Middlewares {
+ // Apply middleware-specific processing based on type
+ switch templates.Middlewares[i].Type {
+ case "headers":
+ processHeadersMiddleware(&templates.Middlewares[i].Config)
+ case "redirectRegex", "redirectScheme", "replacePath", "replacePathRegex", "stripPrefix", "stripPrefixRegex":
+ processPathMiddleware(&templates.Middlewares[i].Config, templates.Middlewares[i].Type)
+ case "basicAuth", "digestAuth", "forwardAuth":
+ processAuthMiddleware(&templates.Middlewares[i].Config, templates.Middlewares[i].Type)
+ case "plugin":
+ processPluginMiddleware(&templates.Middlewares[i].Config)
+ case "chain":
+ processChainingMiddleware(&templates.Middlewares[i].Config)
+ default:
+ // General processing for other middleware types
+ templates.Middlewares[i].Config = preserveTraefikValues(templates.Middlewares[i].Config).(map[string]interface{})
+ }
+ }
+
+ // Create a custom YAML encoder that preserves string formatting
+ yamlNode := &yaml.Node{}
+ err := yamlNode.Encode(templates)
if err != nil {
- return err
+ return fmt.Errorf("failed to encode templates to YAML node: %w", err)
+ }
+
+ // Apply additional string preservation to the YAML node
+ preserveStringsInYamlNode(yamlNode)
+
+ // Marshal the processed node
+ data, err := yaml.Marshal(yamlNode)
+ if err != nil {
+ return fmt.Errorf("failed to marshal YAML node: %w", err)
}
// Write to file
return ioutil.WriteFile(templatesFile, data, 0644)
+}
+
+// preserveStringsInYamlNode ensures that string values, especially empty strings,
+// are preserved correctly in the YAML node structure before marshaling
+func preserveStringsInYamlNode(node *yaml.Node) {
+ if node == nil {
+ return
+ }
+
+ // Process node based on its kind
+ switch node.Kind {
+ case yaml.DocumentNode, yaml.SequenceNode:
+ // Process all content/items
+ for i := range node.Content {
+ preserveStringsInYamlNode(node.Content[i])
+ }
+
+ case yaml.MappingNode:
+ // Process all key-value pairs
+ for i := 0; i < len(node.Content); i += 2 {
+ // Get key and value
+ keyNode := node.Content[i]
+ valueNode := node.Content[i+1]
+
+ // Process based on key content
+ if keyNode.Value == "Server" || keyNode.Value == "X-Powered-By" ||
+ strings.HasPrefix(keyNode.Value, "X-") {
+ // These are likely header fields where empty strings are important
+ if valueNode.Kind == yaml.ScalarNode && valueNode.Value == "" {
+ // Ensure empty strings are properly encoded
+ valueNode.Style = yaml.DoubleQuotedStyle
+ }
+ }
+
+ // Special handling for known fields that need exact string preservation
+ if containsSpecialField(keyNode.Value) && valueNode.Kind == yaml.ScalarNode {
+ // Use double quotes for these fields to ensure proper encoding
+ valueNode.Style = yaml.DoubleQuotedStyle
+ }
+
+ // Continue recursion
+ preserveStringsInYamlNode(keyNode)
+ preserveStringsInYamlNode(valueNode)
+ }
+
+ case yaml.ScalarNode:
+ // For scalar nodes (including strings), ensure empty strings are properly quoted
+ if node.Value == "" {
+ node.Style = yaml.DoubleQuotedStyle
+ }
+ }
+}
+
+// containsSpecialField checks if a field name is one that needs special handling
+// for correct string value preservation
+func containsSpecialField(fieldName string) bool {
+ specialFields := []string{
+ "key", "token", "secret", "apiKey", "Key", "Token", "Secret", "Password",
+ "regex", "replacement", "Regex", "path", "scheme", "url", "address", "Path",
+ "prefix", "prefixes", "expression", "rule",
+ }
+
+ for _, field := range specialFields {
+ if strings.Contains(fieldName, field) {
+ return true
+ }
+ }
+
+ return false
}
\ No newline at end of file
diff --git a/config/templates.yaml b/config/templates.yaml
index 933b4d017..56e65aa41 100644
--- a/config/templates.yaml
+++ b/config/templates.yaml
@@ -1,10 +1,11 @@
# Default middleware templates
middlewares:
+ # Authentication middlewares
- id: authelia
name: Authelia
type: forwardAuth
config:
- address: "http://authelia:9091/api/verify?rd=https://auth.yourdomain.com"
+ address: "http://authelia:9091/api/authz/forward-auth"
trustForwardHeader: true
authResponseHeaders:
- "Remote-User"
@@ -25,6 +26,12 @@ middlewares:
- "X-authentik-name"
- "X-authentik-uid"
+ - id: tinyauth
+ name: Tiny Auth
+ type: forwardAuth
+ config:
+ address: "http://tinyauth:10000/api/auth/traefik"
+
- id: basic-auth
name: Basic Auth
type: basicAuth
@@ -32,6 +39,13 @@ middlewares:
users:
- "admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"
+ - id: digest-auth
+ name: Digest Auth
+ type: digestAuth
+ config:
+ users:
+ - "test:traefik:a2688e031edb4be6a3797f3882655c05"
+
- id: jwt-auth
name: JWT Authentication
type: forwardAuth
@@ -43,6 +57,7 @@ middlewares:
- "X-JWT-Name"
- "X-JWT-Email"
+ # Security middlewares
- id: ip-whitelist
name: IP Whitelist
type: ipWhiteList
@@ -52,6 +67,15 @@ middlewares:
- "192.168.1.0/24"
- "10.0.0.0/8"
+ - id: ip-allowlist
+ name: IP Allow List
+ type: ipAllowList
+ config:
+ sourceRange:
+ - "127.0.0.1/32"
+ - "192.168.1.0/24"
+ - "10.0.0.0/8"
+
- id: rate-limit
name: Rate Limit
type: rateLimit
@@ -59,14 +83,6 @@ middlewares:
average: 100
burst: 50
- - id: security-chain
- name: Security Chain
- type: chain
- config:
- middlewares:
- - rate-limit
- - ip-whitelist
-
- id: headers-standard
name: Standard Security Headers
type: headers
@@ -77,30 +93,175 @@ middlewares:
- PUT
browserXssFilter: true
contentTypeNosniff: true
- customFrameOptionsValue: SAMEORIGIN
+ customFrameOptionsValue: "SAMEORIGIN"
customResponseHeaders:
- X-Forwarded-Proto: https
- X-Robots-Tag: none,noarchive,nosnippet,notranslate,noimageindex
- server: ""
+ X-Forwarded-Proto: "https"
+ X-Robots-Tag: "none,noarchive,nosnippet,notranslate,noimageindex"
+ Server: "" # Empty string to remove Server header
+ X-Powered-By: "" # Empty string to remove X-Powered-By header
forceSTSHeader: true
hostsProxyHeaders:
- X-Forwarded-Host
- permissionsPolicy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), vr=()
- referrerPolicy: same-origin
+ permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=(), usb=(), vr=()"
+ referrerPolicy: "same-origin"
sslProxyHeaders:
- X-Forwarded-Proto: https
+ X-Forwarded-Proto: "https"
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 63072000
-
+
+ - id: in-flight-req
+ name: In-Flight Request Limiter
+ type: inFlightReq
+ config:
+ amount: 10
+ sourceCriterion:
+ ipStrategy:
+ depth: 2
+ excludedIPs:
+ - "127.0.0.1/32"
+ requestHost: true # Added this option shown in the examples
+
+ - id: pass-tls-cert
+ name: Pass TLS Client Certificate
+ type: passTLSClientCert
+ config:
+ pem: true
+
+ # Path manipulation middlewares
+ - id: add-prefix
+ name: Add Prefix
+ type: addPrefix
+ config:
+ prefix: "/api"
+
+ - id: strip-prefix
+ name: Strip Prefix
+ type: stripPrefix
+ config:
+ prefixes:
+ - "/api"
+ - "/v1"
+ forceSlash: true
+
+
+ - id: replace-path
+ name: Replace Path
+ type: replacePath
+ config:
+ path: "/foo"
+
+ - id: replace-path-regex
+ name: Replace Path Regex
+ type: replacePathRegex
+ config:
+ regex: "^/foo/(.*)"
+ replacement: "/bar/$1"
+
+ # Redirect middlewares
+ - id: redirect-regex
+ name: Redirect Regex
+ type: redirectRegex
+ config:
+ regex: "^http://(.*)$"
+ replacement: "https://${1}"
+ permanent: true
+
+ - id: redirect-scheme
+ name: Redirect to HTTPS
+ type: redirectScheme
+ config:
+ scheme: "https"
+ port: "443"
+ permanent: true
+
+ # Content processing middlewares
+ - id: compress
+ name: Compress Response
+ type: compress
+ config:
+ excludedContentTypes:
+ - text/event-stream
+ includedContentTypes:
+ - text/html
+ - text/plain
+ - application/json
+ minResponseBodyBytes: 1024
+ encodings:
+ - gzip
+ - br
+
+ - id: buffering
+ name: Request/Response Buffering
+ type: buffering
+ config:
+ maxRequestBodyBytes: 5000000
+ memRequestBodyBytes: 2000000
+ maxResponseBodyBytes: 5000000
+ memResponseBodyBytes: 2000000
+ retryExpression: "IsNetworkError() && Attempts() < 2"
+
+ - id: content-type
+ name: Content Type Auto-Detector
+ type: contentType
+ config: {}
+
+ # Error handling and reliability middlewares
+ - id: circuit-breaker
+ name: Circuit Breaker
+ type: circuitBreaker
+ config:
+ expression: "NetworkErrorRatio() > 0.20 || ResponseCodeRatio(500, 600, 0, 600) > 0.25"
+ checkPeriod: "10s"
+ fallbackDuration: "30s"
+ recoveryDuration: "60s"
+ responseCode: 503
+
+ - id: retry
+ name: Retry Failed Requests
+ type: retry
+ config:
+ attempts: 3
+ initialInterval: "100ms"
+
+ - id: error-pages
+ name: Custom Error Pages
+ type: errors
+ config:
+ status:
+ - "500-599"
+ service: "error-handler-service"
+ query: "/{status}.html"
+
+ - id: grpc-web
+ name: gRPC Web
+ type: grpcWeb
+ config:
+ allowOrigins:
+ - "*"
+
+ # Special use case middlewares
- id: nextcloud-dav
name: Nextcloud WebDAV Redirect
- type: replacepathregex
+ type: replacePathRegex
config:
regex: "^/.well-known/ca(l|rd)dav"
replacement: "/remote.php/dav/"
-# Plugin middleware templates
+ # Custom headers example with properly quoted values
+ - id: custom-headers-example
+ name: Custom Headers Example
+ type: headers
+ config:
+ customRequestHeaders:
+ X-Script-Name: "test"
+ X-Custom-Value: "value with spaces"
+ X-Custom-Request-Header: "" # Empty string to remove header
+ customResponseHeaders:
+ X-Custom-Response-Header: "value"
+ Server: "" # Empty string to remove Server header
+
+ # Plugin middleware templates
- id: "geoblock"
name: "Geoblock"
type: "plugin"
@@ -127,25 +288,25 @@ middlewares:
type: "plugin"
config:
crowdsec:
- enabled: true # Enable CrowdSec plugin
- logLevel: INFO # Log level
- updateIntervalSeconds: 15 # Update interval
- updateMaxFailure: 0 # Update max failure
- defaultDecisionSeconds: 15 # Default decision seconds
- httpTimeoutSeconds: 10 # HTTP timeout
- crowdsecMode: live # CrowdSec mode
- crowdsecAppsecEnabled: true # Enable AppSec
- crowdsecAppsecHost: crowdsec:7422 # CrowdSec IP address which you noted down later
- crowdsecAppsecFailureBlock: true # Block on failure
- crowdsecAppsecUnreachableBlock: true # Block on unreachable
- crowdsecAppsecBodyLimit: 10485760
- crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
- crowdsecLapiHost: crowdsec:8080 # CrowdSec
- crowdsecLapiScheme: http # CrowdSec API scheme
- forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs
- - "0.0.0.0/0" # All IP addresses are trusted for forwarded headers
- clientTrustedIPs: # Client trusted IPs
- - "10.0.0.0/8" # Internal LAN IP addresses
- - "172.16.0.0/12" # Internal LAN IP addresses
- - "192.168.0.0/16" # Internal LAN IP addresses
- - "100.89.137.0/20" # Internal LAN IP addresses
\ No newline at end of file
+ enabled: true
+ logLevel: INFO
+ updateIntervalSeconds: 15
+ updateMaxFailure: 0
+ defaultDecisionSeconds: 15
+ httpTimeoutSeconds: 10
+ crowdsecMode: live
+ crowdsecAppsecEnabled: true
+ crowdsecAppsecHost: "crowdsec:7422"
+ crowdsecAppsecFailureBlock: true
+ crowdsecAppsecUnreachableBlock: true
+ crowdsecAppsecBodyLimit: 10485760 # Using plain number to avoid scientific notation
+ crowdsecLapiKey: "ENwhi7t7wEaFIn3aZTRbXNdowNDs6Ogr9tK/pzAtNz8" # API key with special chars preserved exactly
+ crowdsecLapiHost: "crowdsec:8080"
+ crowdsecLapiScheme: "http"
+ forwardedHeadersTrustedIPs:
+ - "0.0.0.0/0"
+ clientTrustedIPs:
+ - "10.0.0.0/8"
+ - "172.16.0.0/12"
+ - "192.168.0.0/16"
+ - "100.89.137.0/20"
\ No newline at end of file
diff --git a/database/db.go b/database/db.go
index 7934ed203..7a5651980 100644
--- a/database/db.go
+++ b/database/db.go
@@ -102,25 +102,91 @@ func runMigrations(db *sql.DB) error {
// runPostMigrationUpdates handles migrations that SQLite can't do easily in schema migrations
func runPostMigrationUpdates(db *sql.DB) error {
- // Check if we need to add the status column to the resources table
- // SQLite doesn't support ALTER TABLE IF NOT EXISTS, so we need to check first
- var hasStatusColumn bool
+ // Check if existing resources table is missing any of our columns
+ // We'll check for the custom_headers column
+ var hasCustomHeadersColumn bool
err := db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('resources')
- WHERE name = 'status'
- `).Scan(&hasStatusColumn)
+ WHERE name = 'custom_headers'
+ `).Scan(&hasCustomHeadersColumn)
if err != nil {
- return fmt.Errorf("failed to check if status column exists: %w", err)
+ return fmt.Errorf("failed to check if custom_headers column exists: %w", err)
}
- if !hasStatusColumn {
- log.Println("Adding status column to resources table")
- _, err := db.Exec("ALTER TABLE resources ADD COLUMN status TEXT NOT NULL DEFAULT 'active'")
- if err != nil {
- return fmt.Errorf("failed to add status column: %w", err)
+ // If the column doesn't exist, we need to add it to the existing table
+ if !hasCustomHeadersColumn {
+ log.Println("Adding custom_headers column to resources table")
+
+ if _, err := db.Exec("ALTER TABLE resources ADD COLUMN custom_headers TEXT DEFAULT ''"); err != nil {
+ return fmt.Errorf("failed to add custom_headers column: %w", err)
}
+
+ log.Println("Successfully added custom_headers column")
+ }
+ // Check for router_priority column
+ var hasRouterPriorityColumn bool
+ err = db.QueryRow(`
+ SELECT COUNT(*) > 0
+ FROM pragma_table_info('resources')
+ WHERE name = 'router_priority'
+ `).Scan(&hasRouterPriorityColumn)
+
+ if err != nil {
+ return fmt.Errorf("failed to check if router_priority column exists: %w", err)
+ }
+
+ // If the column doesn't exist, add it
+ if !hasRouterPriorityColumn {
+ log.Println("Adding router_priority column to resources table")
+
+ if _, err := db.Exec("ALTER TABLE resources ADD COLUMN router_priority INTEGER DEFAULT 100"); err != nil {
+ return fmt.Errorf("failed to add router_priority column: %w", err)
+ }
+
+ log.Println("Successfully added router_priority column")
+ }
+ // Check for entrypoints column as well (from previous migration)
+ var hasEntrypointsColumn bool
+ err = db.QueryRow(`
+ SELECT COUNT(*) > 0
+ FROM pragma_table_info('resources')
+ WHERE name = 'entrypoints'
+ `).Scan(&hasEntrypointsColumn)
+
+ if err != nil {
+ return fmt.Errorf("failed to check if entrypoints column exists: %w", err)
+ }
+
+ // If the column doesn't exist, add the routing columns too
+ if !hasEntrypointsColumn {
+ log.Println("Adding routing configuration columns to resources table")
+
+ // Add columns for HTTP routing
+ if _, err := db.Exec("ALTER TABLE resources ADD COLUMN entrypoints TEXT DEFAULT 'websecure'"); err != nil {
+ return fmt.Errorf("failed to add entrypoints column: %w", err)
+ }
+
+ // Add columns for TLS certificate configuration
+ if _, err := db.Exec("ALTER TABLE resources ADD COLUMN tls_domains TEXT DEFAULT ''"); err != nil {
+ return fmt.Errorf("failed to add tls_domains column: %w", err)
+ }
+
+ // Add columns for TCP SNI routing
+ if _, err := db.Exec("ALTER TABLE resources ADD COLUMN tcp_enabled INTEGER DEFAULT 0"); err != nil {
+ return fmt.Errorf("failed to add tcp_enabled column: %w", err)
+ }
+
+ if _, err := db.Exec("ALTER TABLE resources ADD COLUMN tcp_entrypoints TEXT DEFAULT 'tcp'"); err != nil {
+ return fmt.Errorf("failed to add tcp_entrypoints column: %w", err)
+ }
+
+ if _, err := db.Exec("ALTER TABLE resources ADD COLUMN tcp_sni_rule TEXT DEFAULT ''"); err != nil {
+ return fmt.Errorf("failed to add tcp_sni_rule column: %w", err)
+ }
+
+ log.Println("Successfully added all routing configuration columns")
}
return nil
@@ -192,7 +258,9 @@ func (db *DB) GetMiddlewares() ([]map[string]interface{}, error) {
// GetResources fetches all resources
func (db *DB) GetResources() ([]map[string]interface{}, error) {
rows, err := db.Query(`
- SELECT r.id, r.host, r.service_id, r.org_id, r.site_id, r.status,
+ SELECT r.id, r.host, r.service_id, r.org_id, r.site_id, r.status,
+ r.entrypoints, r.tls_domains, r.tcp_enabled, r.tcp_entrypoints, r.tcp_sni_rule,
+ r.custom_headers,
GROUP_CONCAT(m.id || ':' || m.name || ':' || rm.priority, ',') as middlewares
FROM resources r
LEFT JOIN resource_middlewares rm ON r.id = rm.resource_id
@@ -206,19 +274,28 @@ func (db *DB) GetResources() ([]map[string]interface{}, error) {
var resources []map[string]interface{}
for rows.Next() {
- var id, host, serviceID, orgID, siteID, status string
+ var id, host, serviceID, orgID, siteID, status, entrypoints, tlsDomains, tcpEntrypoints, tcpSNIRule, customHeaders string
+ var tcpEnabled int
var middlewares sql.NullString
- if err := rows.Scan(&id, &host, &serviceID, &orgID, &siteID, &status, &middlewares); err != nil {
+ if err := rows.Scan(&id, &host, &serviceID, &orgID, &siteID, &status,
+ &entrypoints, &tlsDomains, &tcpEnabled, &tcpEntrypoints, &tcpSNIRule,
+ &customHeaders, &middlewares); err != nil {
return nil, fmt.Errorf("row scan failed: %w", err)
}
resource := map[string]interface{}{
- "id": id,
- "host": host,
- "service_id": serviceID,
- "org_id": orgID,
- "site_id": siteID,
- "status": status,
+ "id": id,
+ "host": host,
+ "service_id": serviceID,
+ "org_id": orgID,
+ "site_id": siteID,
+ "status": status,
+ "entrypoints": entrypoints,
+ "tls_domains": tlsDomains,
+ "tcp_enabled": tcpEnabled > 0,
+ "tcp_entrypoints": tcpEntrypoints,
+ "tcp_sni_rule": tcpSNIRule,
+ "custom_headers": customHeaders,
}
if middlewares.Valid {
@@ -239,18 +316,23 @@ func (db *DB) GetResources() ([]map[string]interface{}, error) {
// GetResource fetches a specific resource by ID
func (db *DB) GetResource(id string) (map[string]interface{}, error) {
- var host, serviceID, orgID, siteID, status string
+ var host, serviceID, orgID, siteID, status, entrypoints, tlsDomains, tcpEntrypoints, tcpSNIRule, customHeaders string
+ var tcpEnabled int
var middlewares sql.NullString
err := db.QueryRow(`
SELECT r.host, r.service_id, r.org_id, r.site_id, r.status,
- GROUP_CONCAT(m.id || ':' || m.name || ':' || rm.priority, ',') as middlewares
+ r.entrypoints, r.tls_domains, r.tcp_enabled, r.tcp_entrypoints, r.tcp_sni_rule,
+ r.custom_headers,
+ GROUP_CONCAT(m.id || ':' || m.name || ':' || rm.priority, ',') as middlewares
FROM resources r
LEFT JOIN resource_middlewares rm ON r.id = rm.resource_id
LEFT JOIN middlewares m ON rm.middleware_id = m.id
WHERE r.id = ?
GROUP BY r.id
- `, id).Scan(&host, &serviceID, &orgID, &siteID, &status, &middlewares)
+ `, id).Scan(&host, &serviceID, &orgID, &siteID, &status,
+ &entrypoints, &tlsDomains, &tcpEnabled, &tcpEntrypoints, &tcpSNIRule,
+ &customHeaders, &middlewares)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("resource not found: %s", id)
@@ -259,12 +341,18 @@ func (db *DB) GetResource(id string) (map[string]interface{}, error) {
}
resource := map[string]interface{}{
- "id": id,
- "host": host,
- "service_id": serviceID,
- "org_id": orgID,
- "site_id": siteID,
- "status": status,
+ "id": id,
+ "host": host,
+ "service_id": serviceID,
+ "org_id": orgID,
+ "site_id": siteID,
+ "status": status,
+ "entrypoints": entrypoints,
+ "tls_domains": tlsDomains,
+ "tcp_enabled": tcpEnabled > 0,
+ "tcp_entrypoints": tcpEntrypoints,
+ "tcp_sni_rule": tcpSNIRule,
+ "custom_headers": customHeaders,
}
if middlewares.Valid {
diff --git a/database/migrations.sql b/database/migrations.sql
index fcb6679a7..dc5a9063b 100644
--- a/database/migrations.sql
+++ b/database/migrations.sql
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS middlewares (
);
-- Resources table stores Pangolin resources
+-- Includes all configuration columns including the router_priority column
CREATE TABLE IF NOT EXISTS resources (
id TEXT PRIMARY KEY,
host TEXT NOT NULL,
@@ -16,6 +17,24 @@ CREATE TABLE IF NOT EXISTS resources (
org_id TEXT NOT NULL,
site_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
+
+ -- HTTP router configuration
+ entrypoints TEXT DEFAULT 'websecure',
+
+ -- TLS certificate configuration
+ tls_domains TEXT DEFAULT '',
+
+ -- TCP SNI routing configuration
+ tcp_enabled INTEGER DEFAULT 0,
+ tcp_entrypoints TEXT DEFAULT 'tcp',
+ tcp_sni_rule TEXT DEFAULT '',
+
+ -- Custom headers configuration
+ custom_headers TEXT DEFAULT '',
+
+ -- Router priority configuration
+ router_priority INTEGER DEFAULT 100,
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -33,10 +52,6 @@ CREATE TABLE IF NOT EXISTS resource_middlewares (
-- Insert default middlewares
INSERT OR IGNORE INTO middlewares (id, name, type, config) VALUES
-('authelia', 'Authelia', 'forwardAuth', '{"address":"http://authelia:9091/api/verify?rd=https://auth.yourdomain.com","trustForwardHeader":true,"authResponseHeaders":["Remote-User","Remote-Groups","Remote-Name","Remote-Email"]}'),
+('authelia', 'Authelia', 'forwardAuth', '{"address":"http://authelia:9091/api/authz/forward-auth","trustForwardHeader":true,"authResponseHeaders":["Remote-User","Remote-Groups","Remote-Name","Remote-Email"]}'),
('authentik', 'Authentik', 'forwardAuth', '{"address":"http://authentik:9000/outpost.goauthentik.io/auth/traefik","trustForwardHeader":true,"authResponseHeaders":["X-authentik-username","X-authentik-groups","X-authentik-email","X-authentik-name","X-authentik-uid"]}'),
-('basic-auth', 'Basic Auth', 'basicAuth', '{"users":["admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"]}');
-
--- Add status column to existing resources if not already present
--- SQLite doesn't support 'IF NOT EXISTS' for columns, so we need a different approach
--- This will be handled through code to make it compatible with SQLite's ALTER TABLE limitations
\ No newline at end of file
+('basic-auth', 'Basic Auth', 'basicAuth', '{"users":["admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"]}');
\ No newline at end of file
diff --git a/models/resource.go b/models/resource.go
index 71ec49381..3190960c8 100644
--- a/models/resource.go
+++ b/models/resource.go
@@ -6,13 +6,30 @@ import (
// Resource represents a Pangolin resource
type Resource struct {
- ID string `json:"id"`
- Host string `json:"host"`
- ServiceID string `json:"service_id"`
- OrgID string `json:"org_id"`
- SiteID string `json:"site_id"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID string `json:"id"`
+ Host string `json:"host"`
+ ServiceID string `json:"service_id"`
+ OrgID string `json:"org_id"`
+ SiteID string `json:"site_id"`
+ Status string `json:"status"`
+
+ // HTTP router configuration
+ Entrypoints string `json:"entrypoints"`
+
+ // TLS certificate configuration
+ TLSDomains string `json:"tls_domains"`
+
+ // TCP SNI routing configuration
+ TCPEnabled bool `json:"tcp_enabled"`
+ TCPEntrypoints string `json:"tcp_entrypoints"`
+ TCPSNIRule string `json:"tcp_sni_rule"`
+
+ // Custom headers configuration
+ CustomHeaders string `json:"custom_headers"`
+
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+
// Middlewares is a list of associated middlewares, populated when needed
Middlewares []ResourceMiddleware `json:"middlewares,omitempty"`
}
diff --git a/screenshots/dashboard.jpeg b/screenshots/dashboard.jpeg
new file mode 100644
index 000000000..990140c73
Binary files /dev/null and b/screenshots/dashboard.jpeg differ
diff --git a/screenshots/dashboard2.jpeg b/screenshots/dashboard2.jpeg
new file mode 100644
index 000000000..507abfb2e
Binary files /dev/null and b/screenshots/dashboard2.jpeg differ
diff --git a/screenshots/middleware-list.jpeg b/screenshots/middleware-list.jpeg
new file mode 100644
index 000000000..09d1109e4
Binary files /dev/null and b/screenshots/middleware-list.jpeg differ
diff --git a/services/config_generator.go b/services/config_generator.go
index 3d2708ac7..6f480cb2d 100644
--- a/services/config_generator.go
+++ b/services/config_generator.go
@@ -1,12 +1,15 @@
package services
import (
+ "database/sql"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
+ "sort"
"strings"
+ "strconv"
"sync"
"time"
@@ -31,6 +34,10 @@ type TraefikConfig struct {
Middlewares map[string]interface{} `yaml:"middlewares,omitempty"`
Routers map[string]interface{} `yaml:"routers,omitempty"`
} `yaml:"http"`
+
+ TCP struct {
+ Routers map[string]interface{} `yaml:"routers,omitempty"`
+ } `yaml:"tcp,omitempty"`
}
// NewConfigGenerator creates a new config generator
@@ -97,6 +104,145 @@ func (cg *ConfigGenerator) Stop() {
cg.isRunning = false
}
+// preserveTraefikValues ensures all values in Traefik configurations are properly handled
+// This handles special cases in different middleware types and ensures precise value preservation
+func preserveTraefikValues(data interface{}) interface{} {
+ if data == nil {
+ return nil
+ }
+
+ switch v := data.(type) {
+ case map[string]interface{}:
+ // Process each key-value pair in the map
+ for key, val := range v {
+ // Process values based on key names that might need special handling
+ switch {
+ // URL or path related fields
+ case key == "path" || key == "url" || key == "address" || strings.HasSuffix(key, "Path"):
+ // Ensure path strings keep their exact format
+ if strVal, ok := val.(string); ok && strVal != "" {
+ // Keep exact string formatting
+ v[key] = strVal
+ } else {
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // Regex and replacement patterns
+ case key == "regex" || key == "replacement" || strings.HasSuffix(key, "Regex"):
+ // Ensure regex patterns are preserved exactly
+ if strVal, ok := val.(string); ok && strVal != "" {
+ // Keep exact string formatting with special characters
+ v[key] = strVal
+ } else {
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // API keys and security tokens
+ case key == "key" || key == "token" || key == "secret" ||
+ strings.Contains(key, "Key") || strings.Contains(key, "Token") ||
+ strings.Contains(key, "Secret") || strings.Contains(key, "Password"):
+ // Ensure API keys and tokens are preserved exactly
+ if strVal, ok := val.(string); ok {
+ // Always preserve keys/tokens exactly as-is, even if empty
+ v[key] = strVal
+ } else {
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // Empty header values (common in security headers middleware)
+ case key == "Server" || key == "X-Powered-By" || strings.HasPrefix(key, "X-"):
+ // Empty string values are often used to remove headers
+ if strVal, ok := val.(string); ok {
+ // Preserve empty strings exactly
+ v[key] = strVal
+ } else {
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // IP addresses and networks
+ case key == "ip" || key == "clientIP" || strings.Contains(key, "IP") ||
+ key == "sourceRange" || key == "excludedIPs":
+ // IP addresses often need exact formatting
+ v[key] = preserveTraefikValues(val)
+
+ // Boolean flags that control behavior
+ case strings.HasPrefix(key, "is") || strings.HasPrefix(key, "has") ||
+ strings.HasPrefix(key, "enable") || strings.HasSuffix(key, "enabled") ||
+ strings.HasSuffix(key, "Enabled") || key == "permanent" || key == "forceSlash":
+ // Ensure boolean values are preserved as actual booleans
+ if boolVal, ok := val.(bool); ok {
+ v[key] = boolVal
+ } else if strVal, ok := val.(string); ok {
+ // Convert string "true"/"false" to actual boolean if needed
+ if strVal == "true" {
+ v[key] = true
+ } else if strVal == "false" {
+ v[key] = false
+ } else {
+ v[key] = strVal // Keep as is if not a boolean string
+ }
+ } else {
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // Integer values like timeouts, ports, limits
+ case key == "amount" || key == "burst" || key == "port" ||
+ strings.HasSuffix(key, "Seconds") || strings.HasSuffix(key, "Limit") ||
+ strings.HasSuffix(key, "Timeout") || strings.HasSuffix(key, "Size") ||
+ key == "depth" || key == "priority" || key == "statusCode" ||
+ key == "attempts" || key == "responseCode":
+ // Ensure numeric values are preserved as numbers
+ switch numVal := val.(type) {
+ case int:
+ v[key] = numVal
+ case float64:
+ // Keep as float if it has decimal part, otherwise convert to int
+ if numVal == float64(int(numVal)) {
+ v[key] = int(numVal)
+ } else {
+ v[key] = numVal
+ }
+ case string:
+ // Try to convert string to number if it looks like one
+ if i, err := strconv.Atoi(numVal); err == nil {
+ v[key] = i
+ } else if f, err := strconv.ParseFloat(numVal, 64); err == nil {
+ v[key] = f
+ } else {
+ v[key] = numVal // Keep as string if not numeric
+ }
+ default:
+ v[key] = preserveTraefikValues(val)
+ }
+
+ // Default handling for other keys
+ default:
+ v[key] = preserveTraefikValues(val)
+ }
+ }
+ return v
+
+ case []interface{}:
+ // Process each element in the array
+ for i, item := range v {
+ v[i] = preserveTraefikValues(item)
+ }
+ return v
+
+ case string:
+ // Preserve all string values exactly as they are
+ return v
+
+ case int, float64, bool:
+ // Preserve primitive types as they are
+ return v
+
+ default:
+ // For any other type, return as is
+ return v
+ }
+}
+
// generateConfig generates Traefik configuration files
func (cg *ConfigGenerator) generateConfig() error {
log.Println("Generating Traefik configuration...")
@@ -105,21 +251,41 @@ func (cg *ConfigGenerator) generateConfig() error {
config := TraefikConfig{}
config.HTTP.Middlewares = make(map[string]interface{})
config.HTTP.Routers = make(map[string]interface{})
+ config.TCP.Routers = make(map[string]interface{})
// Process middlewares
if err := cg.processMiddlewares(&config); err != nil {
return fmt.Errorf("failed to process middlewares: %w", err)
}
- // Process resources
+ // Process HTTP resources
if err := cg.processResources(&config); err != nil {
- return fmt.Errorf("failed to process resources: %w", err)
+ return fmt.Errorf("failed to process HTTP resources: %w", err)
+ }
+
+ // Process TCP resources
+ if err := cg.processTCPRouters(&config); err != nil {
+ return fmt.Errorf("failed to process TCP resources: %w", err)
}
- // Convert to YAML
- yamlData, err := yaml.Marshal(config)
+ // Process the config to ensure all values are correctly preserved
+ // This handles all middleware types and their specific requirements
+ processedConfig := preserveTraefikValues(config)
+
+ // Convert to YAML using a custom marshaler with string preservation
+ yamlNode := &yaml.Node{}
+ err := yamlNode.Encode(processedConfig)
+ if err != nil {
+ return fmt.Errorf("failed to encode config to YAML node: %w", err)
+ }
+
+ // Preserve string values, especially empty strings, during YAML encoding
+ preserveStringsInYamlNode(yamlNode)
+
+ // Marshal the processed node
+ yamlData, err := yaml.Marshal(yamlNode)
if err != nil {
- return fmt.Errorf("failed to convert config to YAML: %w", err)
+ return fmt.Errorf("failed to marshal YAML node: %w", err)
}
// Check if configuration has changed
@@ -136,6 +302,75 @@ func (cg *ConfigGenerator) generateConfig() error {
return nil
}
+// preserveStringsInYamlNode ensures that string values, especially empty strings,
+// are preserved correctly in the YAML node structure before marshaling
+func preserveStringsInYamlNode(node *yaml.Node) {
+ if node == nil {
+ return
+ }
+
+ // Process node based on its kind
+ switch node.Kind {
+ case yaml.DocumentNode, yaml.SequenceNode:
+ // Process all content/items
+ for i := range node.Content {
+ preserveStringsInYamlNode(node.Content[i])
+ }
+
+ case yaml.MappingNode:
+ // Process all key-value pairs
+ for i := 0; i < len(node.Content); i += 2 {
+ // Get key and value
+ keyNode := node.Content[i]
+ valueNode := node.Content[i+1]
+
+ // Process based on key content
+ if keyNode.Value == "Server" || keyNode.Value == "X-Powered-By" ||
+ strings.HasPrefix(keyNode.Value, "X-") {
+ // These are likely header fields where empty strings are important
+ if valueNode.Kind == yaml.ScalarNode && valueNode.Value == "" {
+ // Ensure empty strings are properly encoded
+ valueNode.Style = yaml.DoubleQuotedStyle
+ }
+ }
+
+ // Special handling for known fields that need exact string preservation
+ if containsSpecialField(keyNode.Value) && valueNode.Kind == yaml.ScalarNode {
+ // Use double quotes for these fields to ensure proper encoding
+ valueNode.Style = yaml.DoubleQuotedStyle
+ }
+
+ // Continue recursion
+ preserveStringsInYamlNode(keyNode)
+ preserveStringsInYamlNode(valueNode)
+ }
+
+ case yaml.ScalarNode:
+ // For scalar nodes (including strings), ensure empty strings are properly quoted
+ if node.Value == "" {
+ node.Style = yaml.DoubleQuotedStyle
+ }
+ }
+}
+
+// containsSpecialField checks if a field name is one that needs special handling
+// for correct string value preservation
+func containsSpecialField(fieldName string) bool {
+ specialFields := []string{
+ "key", "token", "secret", "apiKey", "Key", "Token", "Secret", "Password",
+ "regex", "replacement", "Regex", "path", "scheme", "url", "address", "Path",
+ "prefix", "prefixes", "expression", "rule",
+ }
+
+ for _, field := range specialFields {
+ if strings.Contains(fieldName, field) {
+ return true
+ }
+ }
+
+ return false
+}
+
// hasConfigurationChanged checks if the configuration has changed
func (cg *ConfigGenerator) hasConfigurationChanged(newConfig []byte) bool {
// If we don't have a previous configuration, this is the first run
@@ -202,20 +437,39 @@ func (cg *ConfigGenerator) processMiddlewares(config *TraefikConfig) error {
continue
}
- // Special handling for chain middlewares to ensure proper provider prefixes
- if typ == "chain" && middlewareConfig["middlewares"] != nil {
- if middlewares, ok := middlewareConfig["middlewares"].([]interface{}); ok {
- for i, middleware := range middlewares {
- if middlewareStr, ok := middleware.(string); ok {
- // If this is not already a fully qualified middleware reference
- if !strings.Contains(middlewareStr, "@") {
- // Assume it's from our file provider
- middlewares[i] = fmt.Sprintf("%s@file", middlewareStr)
- }
- }
- }
- middlewareConfig["middlewares"] = middlewares
- }
+ // Process middleware config based on type
+ switch typ {
+ case "chain":
+ // Special handling for chain middlewares
+ processChainingMiddleware(middlewareConfig)
+
+ case "headers":
+ // Special handling for headers middleware (empty strings are important)
+ processHeadersMiddleware(middlewareConfig)
+
+ case "redirectRegex", "redirectScheme", "replacePath", "replacePathRegex", "stripPrefix", "stripPrefixRegex":
+ // Path manipulation middlewares need special handling for regex and path values
+ processPathMiddleware(middlewareConfig)
+
+ case "basicAuth", "digestAuth", "forwardAuth":
+ // Authentication middlewares often have URLs and tokens
+ processAuthMiddleware(middlewareConfig, typ)
+
+ case "inFlightReq", "rateLimit":
+ // Request limiting middlewares have numeric values and IP rules
+ processRateLimitingMiddleware(middlewareConfig, typ)
+
+ case "ipWhiteList", "ipAllowList":
+ // IP filtering middlewares need their CIDR ranges preserved exactly
+ processIPFilteringMiddleware(middlewareConfig)
+
+ case "plugin":
+ // Plugin middlewares (CrowdSec, etc.) need special handling
+ processPluginMiddleware(middlewareConfig)
+
+ default:
+ // General processing for other middleware types
+ middlewareConfig = preserveTraefikValues(middlewareConfig).(map[string]interface{})
}
// Add middleware to config
@@ -231,86 +485,590 @@ func (cg *ConfigGenerator) processMiddlewares(config *TraefikConfig) error {
return nil
}
-// processResources fetches and processes all resources and their middlewares
-func (cg *ConfigGenerator) processResources(config *TraefikConfig) error {
- // Fetch all resources and their middlewares
- rows, err := cg.db.Query(`
- SELECT r.id, r.host, r.service_id, rm.middleware_id, rm.priority
- FROM resources r
- JOIN resource_middlewares rm ON r.id = rm.resource_id
- ORDER BY rm.priority DESC
- `)
- if err != nil {
- return fmt.Errorf("failed to fetch resources: %w", err)
+// processChainingMiddleware handles chain middleware special processing
+func processChainingMiddleware(config map[string]any) {
+ // Do not add any suffixes to middleware names in the chain
+ if middlewares, ok := config["middlewares"].([]any); ok {
+ for i, middleware := range middlewares {
+ if middlewareStr, ok := middleware.(string); ok {
+ // Keep middleware name as is, do not append "@file"
+ middlewares[i] = middlewareStr
+ }
+ }
+ config["middlewares"] = middlewares
}
- defer rows.Close()
- // Group middlewares by resource
- resourceMiddlewares := make(map[string][]string)
- resourceInfo := make(map[string]struct {
- Host string
- ServiceID string
- })
+ // Process other chain configuration values
+ preserveTraefikValues(config)
+}
- for rows.Next() {
- var resourceID, host, serviceID, middlewareID string
- var priority int
- if err := rows.Scan(&resourceID, &host, &serviceID, &middlewareID, &priority); err != nil {
- log.Printf("Failed to scan resource middleware: %v", err)
- continue
+// processHeadersMiddleware handles the headers middleware special processing
+func processHeadersMiddleware(config map[string]interface{}) {
+ // Special handling for response headers which might contain empty strings
+ if customResponseHeaders, ok := config["customResponseHeaders"].(map[string]interface{}); ok {
+ for key, value := range customResponseHeaders {
+ // Ensure empty strings are preserved exactly
+ if strValue, ok := value.(string); ok {
+ customResponseHeaders[key] = strValue
+ }
}
+ }
+
+ // Special handling for request headers which might contain empty strings
+ if customRequestHeaders, ok := config["customRequestHeaders"].(map[string]interface{}); ok {
+ for key, value := range customRequestHeaders {
+ // Ensure empty strings are preserved exactly
+ if strValue, ok := value.(string); ok {
+ customRequestHeaders[key] = strValue
+ }
+ }
+ }
+
+ // Process header fields that are often quoted strings
+ specialStringFields := []string{
+ "customFrameOptionsValue", "contentSecurityPolicy",
+ "referrerPolicy", "permissionsPolicy",
+ }
+
+ for _, field := range specialStringFields {
+ if value, ok := config[field].(string); ok {
+ // Preserve string exactly, especially if it contains quotes
+ config[field] = value
+ }
+ }
+
+ // Process other header configuration values
+ preserveTraefikValues(config)
+}
- resourceMiddlewares[resourceID] = append(resourceMiddlewares[resourceID], middlewareID)
- resourceInfo[resourceID] = struct {
- Host string
- ServiceID string
- }{
- Host: host,
- ServiceID: serviceID,
+// processPathMiddleware handles path manipulation middlewares
+func processPathMiddleware(config map[string]interface{}) {
+ // Special handling for regex patterns - these need exact preservation
+ if regex, ok := config["regex"].(string); ok {
+ // Preserve regex pattern exactly
+ config["regex"] = regex
+ } else if regexList, ok := config["regex"].([]interface{}); ok {
+ // Handle regex arrays (like in stripPrefixRegex)
+ for i, r := range regexList {
+ if regexStr, ok := r.(string); ok {
+ regexList[i] = regexStr
+ }
}
}
+
+ // Special handling for replacement patterns
+ if replacement, ok := config["replacement"].(string); ok {
+ // Preserve replacement pattern exactly
+ config["replacement"] = replacement
+ }
+
+ // Special handling for path values
+ if path, ok := config["path"].(string); ok {
+ // Preserve path exactly
+ config["path"] = path
+ }
+
+ // Special handling for prefixes arrays
+ if prefixes, ok := config["prefixes"].([]interface{}); ok {
+ for i, prefix := range prefixes {
+ if prefixStr, ok := prefix.(string); ok {
+ prefixes[i] = prefixStr
+ }
+ }
+ }
+
+ // Special handling for scheme
+ if scheme, ok := config["scheme"].(string); ok {
+ // Preserve scheme exactly
+ config["scheme"] = scheme
+ }
+
+ // Process boolean options
+ if forceSlash, ok := config["forceSlash"].(bool); ok {
+ config["forceSlash"] = forceSlash
+ }
+
+ if permanent, ok := config["permanent"].(bool); ok {
+ config["permanent"] = permanent
+ }
+
+ // Process other path manipulation configuration values
+ preserveTraefikValues(config)
+}
- if err := rows.Err(); err != nil {
- return fmt.Errorf("error during resource rows iteration: %w", err)
+// processAuthMiddleware handles authentication middleware special processing
+func processAuthMiddleware(config map[string]interface{}, middlewareType string) {
+ // ForwardAuth middleware special handling
+ if middlewareType == "forwardAuth" {
+ if address, ok := config["address"].(string); ok {
+ // Preserve address URL exactly
+ config["address"] = address
+ }
+
+ // Process trust settings
+ if trustForward, ok := config["trustForwardHeader"].(bool); ok {
+ config["trustForwardHeader"] = trustForward
+ }
+
+ // Process headers array
+ if headers, ok := config["authResponseHeaders"].([]interface{}); ok {
+ for i, header := range headers {
+ if headerStr, ok := header.(string); ok {
+ headers[i] = headerStr
+ }
+ }
+ }
}
+
+ // BasicAuth/DigestAuth middleware special handling
+ if middlewareType == "basicAuth" || middlewareType == "digestAuth" {
+ // Preserve exact format of users array
+ if users, ok := config["users"].([]interface{}); ok {
+ for i, user := range users {
+ if userStr, ok := user.(string); ok {
+ users[i] = userStr
+ }
+ }
+ }
+ }
+
+ // Process other auth configuration values
+ preserveTraefikValues(config)
+}
- // Create routers for resources with custom middlewares
- for resourceID, middlewares := range resourceMiddlewares {
- info, exists := resourceInfo[resourceID]
- if !exists {
- log.Printf("Warning: Resource info not found for %s", resourceID)
- continue
+// processRateLimitingMiddleware handles rate limiting middleware special processing
+func processRateLimitingMiddleware(config map[string]interface{}, middlewareType string) {
+ // Process numeric values
+ if average, ok := config["average"].(float64); ok {
+ // Convert to int if it's a whole number
+ if average == float64(int(average)) {
+ config["average"] = int(average)
+ } else {
+ config["average"] = average
+ }
+ }
+
+ if burst, ok := config["burst"].(float64); ok {
+ // Convert to int if it's a whole number
+ if burst == float64(int(burst)) {
+ config["burst"] = int(burst)
+ } else {
+ config["burst"] = burst
+ }
+ }
+
+ if amount, ok := config["amount"].(float64); ok {
+ // Convert to int if it's a whole number
+ if amount == float64(int(amount)) {
+ config["amount"] = int(amount)
+ } else {
+ config["amount"] = amount
+ }
+ }
+
+ // Process sourceCriterion for inFlightReq
+ if sourceCriterion, ok := config["sourceCriterion"].(map[string]interface{}); ok {
+ // Process IP strategy
+ if ipStrategy, ok := sourceCriterion["ipStrategy"].(map[string]interface{}); ok {
+ // Process depth
+ if depth, ok := ipStrategy["depth"].(float64); ok {
+ ipStrategy["depth"] = int(depth)
+ }
+
+ // Process excluded IPs
+ if excludedIPs, ok := ipStrategy["excludedIPs"].([]interface{}); ok {
+ for i, ip := range excludedIPs {
+ if ipStr, ok := ip.(string); ok {
+ excludedIPs[i] = ipStr
+ }
+ }
+ }
}
- // Add "badger" middleware with http provider suffix if not already present
- if !stringSliceContains(middlewares, "badger@http") {
- middlewares = append(middlewares, "badger@http")
+ // Process requestHost boolean
+ if requestHost, ok := sourceCriterion["requestHost"].(bool); ok {
+ sourceCriterion["requestHost"] = requestHost
}
+ }
+
+ // Process other rate limiting configuration values
+ preserveTraefikValues(config)
+}
- // Process middleware references to add provider suffixes
- for i, middleware := range middlewares {
- // If this is not already a fully qualified middleware reference and not the Pangolin badger middleware
- if !strings.Contains(middleware, "@") && middleware != "badger@http" {
- // Assume it's from our file provider
- middlewares[i] = fmt.Sprintf("%s@file", middleware)
+// processIPFilteringMiddleware handles IP filtering middleware special processing
+func processIPFilteringMiddleware(config map[string]interface{}) {
+ // Process sourceRange IPs
+ if sourceRange, ok := config["sourceRange"].([]interface{}); ok {
+ for i, range_ := range sourceRange {
+ if rangeStr, ok := range_.(string); ok {
+ // Preserve IP CIDR notation exactly
+ sourceRange[i] = rangeStr
}
}
+ }
+
+ // Process other IP filtering configuration values
+ preserveTraefikValues(config)
+}
- // Create a router with higher priority
- customRouterID := fmt.Sprintf("%s-auth", resourceID)
-
- config.HTTP.Routers[customRouterID] = map[string]interface{}{
- "rule": fmt.Sprintf("Host(`%s`)", info.Host),
- "service": fmt.Sprintf("%s@http", info.ServiceID), // Reference service from http provider
- "entryPoints": []string{"websecure"},
- "middlewares": middlewares,
- "priority": 100, // Higher than Pangolin's default
- "tls": map[string]interface{}{
- "certResolver": "letsencrypt",
- },
+// processPluginMiddleware handles plugin middleware special processing
+func processPluginMiddleware(config map[string]interface{}) {
+ // Process plugins (including CrowdSec)
+ for _, pluginCfg := range config {
+ if pluginConfig, ok := pluginCfg.(map[string]interface{}); ok {
+ // Process special fields in plugin configurations
+
+ // Process API keys and secrets - must be preserved exactly
+ keyFields := []string{
+ "crowdsecLapiKey", "apiKey", "token", "secret", "password",
+ "key", "accessKey", "secretKey", "captchaSiteKey", "captchaSecretKey",
+ }
+
+ for _, field := range keyFields {
+ if val, exists := pluginConfig[field]; exists {
+ if strVal, ok := val.(string); ok {
+ // Ensure keys are preserved exactly as-is
+ pluginConfig[field] = strVal
+ }
+ }
+ }
+
+ // Process boolean options
+ boolFields := []string{
+ "enabled", "failureBlock", "unreachableBlock", "insecureVerify",
+ "allowLocalRequests", "logLocalRequests", "logAllowedRequests",
+ "logApiRequests", "silentStartUp", "forceMonthlyUpdate",
+ "allowUnknownCountries", "blackListMode", "addCountryHeader",
+ }
+
+ for _, field := range boolFields {
+ for configKey, val := range pluginConfig {
+ if strings.Contains(configKey, field) {
+ if boolVal, ok := val.(bool); ok {
+ pluginConfig[configKey] = boolVal
+ }
+ }
+ }
+ }
+
+ // Process arrays
+ arrayFields := []string{
+ "forwardedHeadersTrustedIPs", "clientTrustedIPs", "countries",
+ }
+
+ for _, field := range arrayFields {
+ for configKey, val := range pluginConfig {
+ if strings.Contains(configKey, field) {
+ if arrayVal, ok := val.([]interface{}); ok {
+ for i, item := range arrayVal {
+ if strItem, ok := item.(string); ok {
+ arrayVal[i] = strItem
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Process remaining fields
+ preserveTraefikValues(pluginConfig)
}
}
+}
+
+// MiddlewareWithPriority represents a middleware with its priority value
+type MiddlewareWithPriority struct {
+ ID string
+ Priority int
+}
+
+// processResources fetches and processes all resources and their middlewares
+func (cg *ConfigGenerator) processResources(config *TraefikConfig) error {
+ // Fetch all active resources with custom headers and router priority
+ rows, err := cg.db.Query(`
+ SELECT r.id, r.host, r.service_id, r.entrypoints, r.tls_domains,
+ r.custom_headers, r.router_priority, rm.middleware_id, rm.priority
+ FROM resources r
+ LEFT JOIN resource_middlewares rm ON r.id = rm.resource_id
+ WHERE r.status = 'active'
+ ORDER BY r.id, rm.priority DESC
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to fetch resources: %w", err)
+ }
+ defer rows.Close()
+
+ // Group middlewares by resource and preserve priority
+ resourceMiddlewares := make(map[string][]MiddlewareWithPriority)
+ resourceInfo := make(map[string]struct {
+ Host string
+ ServiceID string
+ Entrypoints string
+ TLSDomains string
+ CustomHeaders string
+ RouterPriority int
+ })
+
+ for rows.Next() {
+ var resourceID, host, serviceID, entrypoints, tlsDomains, customHeaders string
+ var routerPriority sql.NullInt64
+ var middlewareID sql.NullString
+ var middlewarePriority sql.NullInt64
+
+ if err := rows.Scan(&resourceID, &host, &serviceID, &entrypoints, &tlsDomains,
+ &customHeaders, &routerPriority, &middlewareID, &middlewarePriority); err != nil {
+ log.Printf("Failed to scan resource middleware: %v", err)
+ continue
+ }
+
+ // Set default router priority if null
+ priority := 100 // Default priority
+ if routerPriority.Valid {
+ priority = int(routerPriority.Int64)
+ }
+
+ // Store resource info and router priority
+ resourceInfo[resourceID] = struct {
+ Host string
+ ServiceID string
+ Entrypoints string
+ TLSDomains string
+ CustomHeaders string
+ RouterPriority int
+ }{
+ Host: host,
+ ServiceID: serviceID,
+ Entrypoints: entrypoints,
+ TLSDomains: tlsDomains,
+ CustomHeaders: customHeaders,
+ RouterPriority: priority,
+ }
+
+ if middlewareID.Valid {
+ middleware := MiddlewareWithPriority{
+ ID: middlewareID.String,
+ Priority: int(middlewarePriority.Int64),
+ }
+ resourceMiddlewares[resourceID] = append(resourceMiddlewares[resourceID], middleware)
+ }
+ }
+
+ if err := rows.Err(); err != nil {
+ return fmt.Errorf("error during resource rows iteration: %w", err)
+ }
+
+ // Create routers for resources with custom middlewares
+ for resourceID, middlewares := range resourceMiddlewares {
+ info, exists := resourceInfo[resourceID]
+ if !exists {
+ log.Printf("Warning: Resource info not found for %s", resourceID)
+ continue
+ }
+
+ // Sort middlewares by priority (higher numbers first)
+ sort.Slice(middlewares, func(i, j int) bool {
+ return middlewares[i].Priority > middlewares[j].Priority
+ })
+
+ // Process entrypoints (comma-separated list to array)
+ entrypoints := []string{"websecure"} // Default
+ if info.Entrypoints != "" {
+ // Split by comma and trim spaces
+ rawEntrypoints := strings.Split(info.Entrypoints, ",")
+ entrypoints = make([]string, 0, len(rawEntrypoints))
+ for _, ep := range rawEntrypoints {
+ trimmed := strings.TrimSpace(ep)
+ if trimmed != "" {
+ entrypoints = append(entrypoints, trimmed)
+ }
+ }
+
+ // If after processing we have no valid entrypoints, use the default
+ if len(entrypoints) == 0 {
+ entrypoints = []string{"websecure"}
+ }
+ }
+
+ // Process custom headers if present
+ var customHeadersMiddleware string
+ if info.CustomHeaders != "" && info.CustomHeaders != "{}" && info.CustomHeaders != "null" {
+ // Parse the custom headers
+ var customHeaders map[string]string
+ if err := json.Unmarshal([]byte(info.CustomHeaders), &customHeaders); err != nil {
+ log.Printf("Failed to parse custom headers for resource %s: %v", resourceID, err)
+ } else if len(customHeaders) > 0 {
+ // Create a custom headers middleware
+ customHeadersMiddlewareID := fmt.Sprintf("%s-custom-headers", resourceID)
+
+ // Preserve empty strings and special characters in custom headers
+ processedHeaders := make(map[string]interface{})
+ for k, v := range customHeaders {
+ processedHeaders[k] = v
+ }
+
+ // Add the middleware to the config
+ config.HTTP.Middlewares[customHeadersMiddlewareID] = map[string]interface{}{
+ "headers": map[string]interface{}{
+ "customRequestHeaders": processedHeaders,
+ },
+ }
+
+ // Add reference with file provider suffix
+ customHeadersMiddleware = fmt.Sprintf("%s@file", customHeadersMiddlewareID)
+ }
+ }
+
+ // Extract middleware IDs from the sorted slice
+ var middlewareIDs []string
+
+ // Add custom headers middleware at the beginning if it exists
+ if customHeadersMiddleware != "" {
+ middlewareIDs = append(middlewareIDs, customHeadersMiddleware)
+ }
+
+ // Add sorted middlewares
+ for _, mw := range middlewares {
+ middlewareIDs = append(middlewareIDs, mw.ID)
+ }
+
+ // Add "badger" middleware with http provider suffix if not already present
+ if !stringSliceContains(middlewareIDs, "badger@http") {
+ middlewareIDs = append(middlewareIDs, "badger@http")
+ }
+
+ // Process middleware references to add provider suffixes
+ for i, middleware := range middlewareIDs {
+ // If this is not already a fully qualified middleware reference and not the Pangolin badger middleware
+ if !strings.Contains(middleware, "@") && middleware != "badger@http" && middleware != customHeadersMiddleware {
+ // Assume it's from our file provider
+ middlewareIDs[i] = fmt.Sprintf("%s@file", middleware)
+ }
+ }
+ // Create a router with higher priority
+ customRouterID := fmt.Sprintf("%s-auth", resourceID)
+
+ // Basic router configuration - use the resource's router priority
+ routerConfig := map[string]interface{}{
+ "rule": fmt.Sprintf("Host(`%s`)", info.Host),
+ "service": fmt.Sprintf("%s@http", info.ServiceID), // Reference service from http provider
+ "entryPoints": entrypoints,
+ "middlewares": middlewareIDs,
+ "priority": info.RouterPriority, // Use the resource's router priority
+ }
+
+ // Add TLS configuration with optional domains for certificate
+ if info.TLSDomains != "" {
+ // Parse the comma-separated domains
+ domains := strings.Split(info.TLSDomains, ",")
+ // Clean up the domains
+ var cleanDomains []string
+ for _, domain := range domains {
+ domain = strings.TrimSpace(domain)
+ if domain != "" {
+ cleanDomains = append(cleanDomains, domain)
+ }
+ }
+
+ if len(cleanDomains) > 0 {
+ // Create TLS configuration with domains
+ tlsConfig := map[string]interface{}{
+ "certResolver": "letsencrypt",
+ "domains": []map[string]interface{}{
+ {
+ "main": info.Host,
+ "sans": cleanDomains,
+ },
+ },
+ }
+ routerConfig["tls"] = tlsConfig
+ } else {
+ // Default TLS config if no additional domains
+ routerConfig["tls"] = map[string]interface{}{
+ "certResolver": "letsencrypt",
+ }
+ }
+ } else {
+ // Default TLS config
+ routerConfig["tls"] = map[string]interface{}{
+ "certResolver": "letsencrypt",
+ }
+ }
+
+ config.HTTP.Routers[customRouterID] = routerConfig
+ }
+
+ return nil
+}
+
+// processTCPRouters fetches and processes all resources with TCP SNI routing enabled
+func (cg *ConfigGenerator) processTCPRouters(config *TraefikConfig) error {
+ // Fetch resources with TCP routing enabled including router priority
+ rows, err := cg.db.Query(`
+ SELECT id, host, service_id, tcp_entrypoints, tcp_sni_rule, router_priority
+ FROM resources
+ WHERE status = 'active' AND tcp_enabled = 1
+ `)
+ if err != nil {
+ return fmt.Errorf("failed to fetch TCP resources: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var id, host, serviceID, tcpEntrypoints, tcpSNIRule string
+ var routerPriority sql.NullInt64
+
+ if err := rows.Scan(&id, &host, &serviceID, &tcpEntrypoints, &tcpSNIRule, &routerPriority); err != nil {
+ log.Printf("Failed to scan TCP resource: %v", err)
+ continue
+ }
+
+ // Set default router priority if null
+ priority := 100 // Default priority
+ if routerPriority.Valid {
+ priority = int(routerPriority.Int64)
+ }
+
+ // Process TCP entrypoints (comma-separated list to array)
+ entrypoints := []string{"tcp"} // Default
+ if tcpEntrypoints != "" {
+ // Split by comma and trim spaces
+ rawEntrypoints := strings.Split(tcpEntrypoints, ",")
+ entrypoints = make([]string, 0, len(rawEntrypoints))
+ for _, ep := range rawEntrypoints {
+ trimmed := strings.TrimSpace(ep)
+ if trimmed != "" {
+ entrypoints = append(entrypoints, trimmed)
+ }
+ }
+
+ // If after processing we have no valid entrypoints, use the default
+ if len(entrypoints) == 0 {
+ entrypoints = []string{"tcp"}
+ }
+ }
+
+ // Create the rule - default to HostSNI for the domain if no custom rule
+ rule := fmt.Sprintf("HostSNI(`%s`)", host)
+ if tcpSNIRule != "" {
+ rule = tcpSNIRule
+ }
+
+ // Create TCP router config with the specified priority
+ tcpRouterID := fmt.Sprintf("%s-tcp", id)
+ config.TCP.Routers[tcpRouterID] = map[string]interface{}{
+ "rule": rule,
+ "service": serviceID, // Reference service from http provider
+ "entryPoints": entrypoints,
+ "tls": map[string]interface{}{}, // Enable TLS for SNI
+ "priority": priority, // Use the resource's router priority
+ }
+ }
+
+ if err := rows.Err(); err != nil {
+ return fmt.Errorf("error during TCP resources iteration: %w", err)
+ }
+
return nil
}
diff --git a/services/resource_watcher.go b/services/resource_watcher.go
index 5d8b07bb2..6894abf76 100644
--- a/services/resource_watcher.go
+++ b/services/resource_watcher.go
@@ -133,8 +133,8 @@ func (rw *ResourceWatcher) checkResources() error {
// Process routers to find resources
for routerID, router := range config.HTTP.Routers {
// Skip non-SSL routers (usually HTTP redirects)
- if router.TLS.CertResolver == "" {
- continue
+ if router.TLS.CertResolver == "" {
+ continue
}
// Extract host from rule (e.g., "Host(`example.com`)")
@@ -183,9 +183,16 @@ func (rw *ResourceWatcher) updateOrCreateResource(resourceID, host, serviceID st
// Check if resource already exists
var exists int
var status string
- err := rw.db.QueryRow("SELECT 1, status FROM resources WHERE id = ?", resourceID).Scan(&exists, &status)
+ var entrypoints, tlsDomains, tcpEntrypoints, tcpSNIRule string
+ var tcpEnabled int
+
+ err := rw.db.QueryRow(`
+ SELECT 1, status, entrypoints, tls_domains, tcp_enabled, tcp_entrypoints, tcp_sni_rule
+ FROM resources WHERE id = ?
+ `, resourceID).Scan(&exists, &status, &entrypoints, &tlsDomains, &tcpEnabled, &tcpEntrypoints, &tcpSNIRule)
+
if err == nil {
- // Resource exists, update if needed and ensure status is active
+ // Resource exists, update essential fields but preserve custom configuration
_, err = rw.db.Exec(
"UPDATE resources SET host = ?, service_id = ?, status = 'active', updated_at = ? WHERE id = ?",
host, serviceID, time.Now(), resourceID,
@@ -201,11 +208,14 @@ func (rw *ResourceWatcher) updateOrCreateResource(resourceID, host, serviceID st
return nil
}
- // Create new resource (with placeholder org_id and site_id)
- _, err = rw.db.Exec(
- "INSERT INTO resources (id, host, service_id, org_id, site_id, status) VALUES (?, ?, ?, ?, ?, 'active')",
- resourceID, host, serviceID, "unknown", "unknown",
- )
+ // Create new resource with default configuration
+ _, err = rw.db.Exec(`
+ INSERT INTO resources (
+ id, host, service_id, org_id, site_id, status,
+ entrypoints, tls_domains, tcp_enabled, tcp_entrypoints, tcp_sni_rule
+ ) VALUES (?, ?, ?, ?, ?, 'active', 'websecure', '', 0, 'tcp', '')
+ `, resourceID, host, serviceID, "unknown", "unknown")
+
if err != nil {
return fmt.Errorf("failed to create resource %s: %w", resourceID, err)
}
diff --git a/ui/public/index.html b/ui/public/index.html
index 7720b31f8..c5c3dfd1d 100644
--- a/ui/public/index.html
+++ b/ui/public/index.html
@@ -7,7 +7,7 @@
-
+