Add support for external (Traefik) middlewares
Some checks are pending
Build and Push Docker Image / build-and-push (push) Waiting to run
Tests / Run Tests (push) Waiting to run
Tests / Lint (push) Waiting to run
Tests / Build (push) Blocked by required conditions

Introduce support for Traefik-native external middlewares referenced by resources.

- Database: add resource_external_middlewares table in migrations and ensure creation in post-migration updates.
- API: add handlers and routes to assign, remove and list external middlewares; include external_middlewares field in GetResources/GetResource responses (comma-separated name:priority:provider entries). Handlers validate resource status, use transactions, and log errors.
- Services: ConfigProxy now loads external middleware refs, merges them with internal middlewares sorted by priority when building resource config.
- UI: Resource detail component, API client, store and types updated to expose listing, assigning and removing external middlewares with UI controls and confirmation modal.
- Tests: add unit tests for assign/remove/list handlers and inclusion on GetResource.

This enables referencing middlewares defined outside MW-manager (e.g., Traefik dynamic config or plugins) and honors priority/provider metadata.
This commit is contained in:
hhftechnologies 2026-02-12 18:24:05 +05:30
parent a66930b676
commit fca08e470b
11 changed files with 842 additions and 9 deletions

View file

@ -171,6 +171,7 @@ func (h *ResourceHandler) GetResources(c *gin.Context) {
resource["middlewares"] = ""
}
resource["external_middlewares"] = ""
resources = append(resources, resource)
}
@ -180,6 +181,33 @@ func (h *ResourceHandler) GetResources(c *gin.Context) {
return
}
// Batch-load external middlewares for all resources
if len(resources) > 0 {
extRows, err := h.DB.Query(
"SELECT resource_id, middleware_name, priority, provider FROM resource_external_middlewares ORDER BY resource_id, priority DESC",
)
if err != nil {
log.Printf("Warning: failed to fetch external middlewares: %v", err)
} else {
defer extRows.Close()
extMap := make(map[string][]string)
for extRows.Next() {
var resID, name, provider string
var priority int
if err := extRows.Scan(&resID, &name, &priority, &provider); err != nil {
log.Printf("Error scanning external middleware: %v", err)
continue
}
extMap[resID] = append(extMap[resID], fmt.Sprintf("%s:%d:%s", name, priority, provider))
}
for i, res := range resources {
if parts, ok := extMap[res["id"].(string)]; ok {
resources[i]["external_middlewares"] = strings.Join(parts, ",")
}
}
}
}
// Return paginated or regular response
if usePagination {
c.JSON(http.StatusOK, NewPaginatedResponse(resources, total, params))
@ -287,6 +315,29 @@ func (h *ResourceHandler) GetResource(c *gin.Context) {
resource["middlewares"] = ""
}
// Fetch external (Traefik-native) middlewares assigned to this resource
extRows, err := h.DB.Query(
"SELECT middleware_name, priority, provider FROM resource_external_middlewares WHERE resource_id = ? ORDER BY priority DESC",
id,
)
if err != nil {
log.Printf("Error fetching external middlewares for resource %s: %v", id, err)
resource["external_middlewares"] = ""
} else {
defer extRows.Close()
var extParts []string
for extRows.Next() {
var name, provider string
var priority int
if err := extRows.Scan(&name, &priority, &provider); err != nil {
log.Printf("Error scanning external middleware row: %v", err)
continue
}
extParts = append(extParts, fmt.Sprintf("%s:%d:%s", name, priority, provider))
}
resource["external_middlewares"] = strings.Join(extParts, ",")
}
c.JSON(http.StatusOK, resource)
}
@ -808,3 +859,209 @@ func (h *ResourceHandler) RemoveMiddleware(c *gin.Context) {
log.Printf("Successfully removed middleware %s from resource %s", middlewareID, resourceID)
c.JSON(http.StatusOK, gin.H{"message": "Middleware removed from resource successfully"})
}
// AssignExternalMiddleware assigns a Traefik-native middleware to a resource by name
func (h *ResourceHandler) AssignExternalMiddleware(c *gin.Context) {
resourceID := c.Param("id")
if resourceID == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
var input struct {
MiddlewareName string `json:"middleware_name" binding:"required"`
Priority int `json:"priority"`
Provider string `json:"provider"`
}
if err := c.ShouldBindJSON(&input); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Validate middleware name is not empty after trimming
input.MiddlewareName = strings.TrimSpace(input.MiddlewareName)
if input.MiddlewareName == "" {
ResponseWithError(c, http.StatusBadRequest, "Middleware name is required")
return
}
// Default priority
if input.Priority <= 0 {
input.Priority = 100
}
// Verify resource exists and is active
var exists int
var status string
err := h.DB.QueryRow("SELECT 1, status FROM resources WHERE id = ?", resourceID).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
}
if status == "disabled" {
ResponseWithError(c, http.StatusBadRequest, "Cannot assign middleware to a disabled resource")
return
}
// Insert or update using a transaction
tx, err := h.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)
}
}()
// Delete any existing relationship first
_, txErr = tx.Exec(
"DELETE FROM resource_external_middlewares WHERE resource_id = ? AND middleware_name = ?",
resourceID, input.MiddlewareName,
)
if txErr != nil {
log.Printf("Error removing existing external middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Insert new relationship
_, txErr = tx.Exec(
"INSERT INTO resource_external_middlewares (resource_id, middleware_name, priority, provider) VALUES (?, ?, ?, ?)",
resourceID, input.MiddlewareName, input.Priority, input.Provider,
)
if txErr != nil {
log.Printf("Error assigning external middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to assign external middleware")
return
}
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully assigned external middleware %s to resource %s with priority %d",
input.MiddlewareName, resourceID, input.Priority)
c.JSON(http.StatusOK, gin.H{
"resource_id": resourceID,
"middleware_name": input.MiddlewareName,
"priority": input.Priority,
"provider": input.Provider,
})
}
// RemoveExternalMiddleware removes a Traefik-native middleware from a resource
func (h *ResourceHandler) RemoveExternalMiddleware(c *gin.Context) {
resourceID := c.Param("id")
middlewareName := c.Param("name")
if resourceID == "" || middlewareName == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID and Middleware name are required")
return
}
log.Printf("Removing external middleware %s from resource %s", middlewareName, resourceID)
tx, err := h.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)
}
}()
result, txErr := tx.Exec(
"DELETE FROM resource_external_middlewares WHERE resource_id = ? AND middleware_name = ?",
resourceID, middlewareName,
)
if txErr != nil {
log.Printf("Error removing external middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to remove external middleware")
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("Error getting rows affected: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
if rowsAffected == 0 {
ResponseWithError(c, http.StatusNotFound, "External middleware assignment not found")
return
}
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully removed external middleware %s from resource %s", middlewareName, resourceID)
c.JSON(http.StatusOK, gin.H{"message": "External middleware removed from resource successfully"})
}
// GetExternalMiddlewares returns all external middlewares assigned to a resource
func (h *ResourceHandler) GetExternalMiddlewares(c *gin.Context) {
resourceID := c.Param("id")
if resourceID == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
rows, err := h.DB.Query(
"SELECT middleware_name, priority, provider, created_at FROM resource_external_middlewares WHERE resource_id = ? ORDER BY priority DESC",
resourceID,
)
if err != nil {
log.Printf("Error fetching external middlewares: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to fetch external middlewares")
return
}
defer rows.Close()
var externalMiddlewares []gin.H
for rows.Next() {
var name, provider string
var priority int
var createdAt string
if err := rows.Scan(&name, &priority, &provider, &createdAt); err != nil {
log.Printf("Error scanning external middleware row: %v", err)
continue
}
externalMiddlewares = append(externalMiddlewares, gin.H{
"resource_id": resourceID,
"middleware_name": name,
"priority": priority,
"provider": provider,
"created_at": createdAt,
})
}
if externalMiddlewares == nil {
externalMiddlewares = []gin.H{}
}
c.JSON(http.StatusOK, externalMiddlewares)
}

View file

@ -3,6 +3,7 @@ package handlers
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/gin-gonic/gin"
@ -289,3 +290,229 @@ func TestResourceHandler_GetResources_Empty(t *testing.T) {
// Empty result is acceptable (nil or empty slice)
}
// TestResourceHandler_AssignExternalMiddleware tests assigning a Traefik-native middleware
func TestResourceHandler_AssignExternalMiddleware(t *testing.T) {
db := testutil.NewTempDB(t)
handler := NewResourceHandler(db.DB)
testutil.MustExec(t, db, `
INSERT INTO resources (id, pangolin_router_id, host, service_id, org_id, site_id, status, source_type)
VALUES ('ext-res-1', 'pangolin-ext-1', 'ext.example.com', 'svc-1', 'org-1', 'site-1', 'active', 'pangolin')
`)
body := `{"middleware_name": "my-auth@file", "priority": 150, "provider": "file"}`
c, rec := testutil.NewContext(t, http.MethodPost, "/api/resources/ext-res-1/external-middlewares", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "ext-res-1"}}
handler.AssignExternalMiddleware(c)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["middleware_name"] != "my-auth@file" {
t.Errorf("expected middleware_name 'my-auth@file', got %v", resp["middleware_name"])
}
if int(resp["priority"].(float64)) != 150 {
t.Errorf("expected priority 150, got %v", resp["priority"])
}
}
// TestResourceHandler_AssignExternalMiddleware_MissingName tests empty middleware name
func TestResourceHandler_AssignExternalMiddleware_MissingName(t *testing.T) {
db := testutil.NewTempDB(t)
handler := NewResourceHandler(db.DB)
testutil.MustExec(t, db, `
INSERT INTO resources (id, pangolin_router_id, host, service_id, org_id, site_id, status, source_type)
VALUES ('ext-res-2', 'pangolin-ext-2', 'ext2.example.com', 'svc-1', 'org-1', 'site-1', 'active', 'pangolin')
`)
body := `{"priority": 100}`
c, rec := testutil.NewContext(t, http.MethodPost, "/api/resources/ext-res-2/external-middlewares", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "ext-res-2"}}
handler.AssignExternalMiddleware(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestResourceHandler_AssignExternalMiddleware_DisabledResource tests assigning to disabled resource
func TestResourceHandler_AssignExternalMiddleware_DisabledResource(t *testing.T) {
db := testutil.NewTempDB(t)
handler := NewResourceHandler(db.DB)
testutil.MustExec(t, db, `
INSERT INTO resources (id, pangolin_router_id, host, service_id, org_id, site_id, status, source_type)
VALUES ('ext-res-3', 'pangolin-ext-3', 'ext3.example.com', 'svc-1', 'org-1', 'site-1', 'disabled', 'pangolin')
`)
body := `{"middleware_name": "test-mw@file", "priority": 100}`
c, rec := testutil.NewContext(t, http.MethodPost, "/api/resources/ext-res-3/external-middlewares", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Params = gin.Params{{Key: "id", Value: "ext-res-3"}}
handler.AssignExternalMiddleware(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestResourceHandler_RemoveExternalMiddleware tests removing an external middleware
func TestResourceHandler_RemoveExternalMiddleware(t *testing.T) {
db := testutil.NewTempDB(t)
handler := NewResourceHandler(db.DB)
testutil.MustExec(t, db, `
INSERT INTO resources (id, pangolin_router_id, host, service_id, org_id, site_id, status, source_type)
VALUES ('ext-res-4', 'pangolin-ext-4', 'ext4.example.com', 'svc-1', 'org-1', 'site-1', 'active', 'pangolin')
`)
testutil.MustExec(t, db, `
INSERT INTO resource_external_middlewares (resource_id, middleware_name, priority, provider)
VALUES ('ext-res-4', 'test-mw@file', 100, 'file')
`)
c, rec := testutil.NewContext(t, http.MethodDelete, "/api/resources/ext-res-4/external-middlewares/test-mw@file", nil)
c.Params = gin.Params{
{Key: "id", Value: "ext-res-4"},
{Key: "name", Value: "test-mw@file"},
}
handler.RemoveExternalMiddleware(c)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
// Verify it was removed
var count int
db.DB.QueryRow("SELECT COUNT(*) FROM resource_external_middlewares WHERE resource_id = 'ext-res-4'").Scan(&count)
if count != 0 {
t.Error("external middleware was not removed from database")
}
}
// TestResourceHandler_RemoveExternalMiddleware_NotFound tests removing non-existent assignment
func TestResourceHandler_RemoveExternalMiddleware_NotFound(t *testing.T) {
db := testutil.NewTempDB(t)
handler := NewResourceHandler(db.DB)
c, rec := testutil.NewContext(t, http.MethodDelete, "/api/resources/ext-res-5/external-middlewares/nonexistent", nil)
c.Params = gin.Params{
{Key: "id", Value: "ext-res-5"},
{Key: "name", Value: "nonexistent"},
}
handler.RemoveExternalMiddleware(c)
if rec.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestResourceHandler_GetExternalMiddlewares tests listing external middlewares
func TestResourceHandler_GetExternalMiddlewares(t *testing.T) {
db := testutil.NewTempDB(t)
handler := NewResourceHandler(db.DB)
testutil.MustExec(t, db, `
INSERT INTO resources (id, pangolin_router_id, host, service_id, org_id, site_id, status, source_type)
VALUES ('ext-res-6', 'pangolin-ext-6', 'ext6.example.com', 'svc-1', 'org-1', 'site-1', 'active', 'pangolin')
`)
testutil.MustExec(t, db, `
INSERT INTO resource_external_middlewares (resource_id, middleware_name, priority, provider)
VALUES ('ext-res-6', 'auth@file', 200, 'file')
`)
testutil.MustExec(t, db, `
INSERT INTO resource_external_middlewares (resource_id, middleware_name, priority, provider)
VALUES ('ext-res-6', 'rate-limit@docker', 100, 'docker')
`)
c, rec := testutil.NewContext(t, http.MethodGet, "/api/resources/ext-res-6/external-middlewares", nil)
c.Params = gin.Params{{Key: "id", Value: "ext-res-6"}}
handler.GetExternalMiddlewares(c)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var extMiddlewares []map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &extMiddlewares); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(extMiddlewares) != 2 {
t.Fatalf("expected 2 external middlewares, got %d", len(extMiddlewares))
}
// Should be sorted by priority DESC (200 first, then 100)
if extMiddlewares[0]["middleware_name"] != "auth@file" {
t.Errorf("expected first middleware to be 'auth@file', got %v", extMiddlewares[0]["middleware_name"])
}
}
// TestResourceHandler_GetExternalMiddlewares_Empty tests empty result
func TestResourceHandler_GetExternalMiddlewares_Empty(t *testing.T) {
db := testutil.NewTempDB(t)
handler := NewResourceHandler(db.DB)
c, rec := testutil.NewContext(t, http.MethodGet, "/api/resources/ext-res-empty/external-middlewares", nil)
c.Params = gin.Params{{Key: "id", Value: "ext-res-empty"}}
handler.GetExternalMiddlewares(c)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var extMiddlewares []map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &extMiddlewares); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(extMiddlewares) != 0 {
t.Errorf("expected 0 external middlewares, got %d", len(extMiddlewares))
}
}
// TestResourceHandler_GetResource_IncludesExternalMiddlewares tests that GetResource includes external_middlewares
func TestResourceHandler_GetResource_IncludesExternalMiddlewares(t *testing.T) {
db := testutil.NewTempDB(t)
handler := NewResourceHandler(db.DB)
testutil.MustExec(t, db, `
INSERT INTO resources (id, pangolin_router_id, host, service_id, org_id, site_id, status, source_type, router_priority)
VALUES ('ext-res-7', 'pangolin-ext-7', 'ext7.example.com', 'svc-1', 'org-1', 'site-1', 'active', 'pangolin', 100)
`)
testutil.MustExec(t, db, `
INSERT INTO resource_external_middlewares (resource_id, middleware_name, priority, provider)
VALUES ('ext-res-7', 'my-plugin@file', 150, 'file')
`)
c, rec := testutil.NewContext(t, http.MethodGet, "/api/resources/ext-res-7", nil)
c.Params = gin.Params{{Key: "id", Value: "ext-res-7"}}
handler.GetResource(c)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resource map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resource); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
extMws, ok := resource["external_middlewares"].(string)
if !ok {
t.Fatalf("external_middlewares field not found or not a string")
}
if extMws != "my-plugin@file:150:file" {
t.Errorf("expected 'my-plugin@file:150:file', got '%s'", extMws)
}
}

View file

@ -186,6 +186,11 @@ func (s *Server) setupRoutes(uiPath string) {
resources.POST("/:id/middlewares/bulk", s.resourceHandler.AssignMultipleMiddlewares)
resources.DELETE("/:id/middlewares/:middlewareId", s.resourceHandler.RemoveMiddleware)
// External (Traefik-native) middleware assignments
resources.GET("/:id/external-middlewares", s.resourceHandler.GetExternalMiddlewares)
resources.POST("/:id/external-middlewares", s.resourceHandler.AssignExternalMiddleware)
resources.DELETE("/:id/external-middlewares/:name", s.resourceHandler.RemoveExternalMiddleware)
// Service assignments
resources.GET("/:id/service", s.serviceHandler.GetResourceService)
resources.POST("/:id/service", s.serviceHandler.AssignServiceToResource)

View file

@ -617,6 +617,35 @@ func runPostMigrationUpdates(db *sql.DB) error {
// Create index on services status for faster filtering
_, _ = db.Exec("CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)")
// Check for resource_external_middlewares table (for Traefik-native middleware references)
var hasExternalMiddlewaresTable bool
err = db.QueryRow(`
SELECT COUNT(*) > 0
FROM sqlite_master
WHERE type='table' AND name='resource_external_middlewares'
`).Scan(&hasExternalMiddlewaresTable)
if err != nil {
return fmt.Errorf("failed to check if resource_external_middlewares table exists: %w", err)
}
if !hasExternalMiddlewaresTable {
log.Println("Creating resource_external_middlewares table")
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS resource_external_middlewares (
resource_id TEXT NOT NULL,
middleware_name TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 100,
provider TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (resource_id, middleware_name),
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
)
`)
if err != nil {
return fmt.Errorf("failed to create resource_external_middlewares table: %w", err)
}
log.Println("Successfully created resource_external_middlewares table")
}
return nil
}

View file

@ -155,4 +155,16 @@ CREATE TABLE IF NOT EXISTS security_config (
);
-- Initialize security config singleton row
INSERT OR IGNORE INTO security_config (id) VALUES (1);
INSERT OR IGNORE INTO security_config (id) VALUES (1);
-- External middlewares table stores references to Traefik-native middlewares assigned to resources
-- These are middlewares defined in Traefik dynamic config or plugins (not managed by MW-manager)
CREATE TABLE IF NOT EXISTS resource_external_middlewares (
resource_id TEXT NOT NULL,
middleware_name TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 100,
provider TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (resource_id, middleware_name),
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);

View file

@ -80,6 +80,11 @@ type middlewareWithPriority struct {
Priority int
}
type externalMiddlewareRef struct {
Name string
Priority int
}
type mtlsConfigData struct {
CACertPath string
Rules []interface{}
@ -109,6 +114,7 @@ type resourceData struct {
TLSHardeningEnabled bool
SecureHeadersEnabled bool
Middlewares []middlewareWithPriority
ExternalMiddlewares []externalMiddlewareRef
CustomServiceID sql.NullString
}
@ -501,11 +507,6 @@ func (cp *ConfigProxy) applyResourceOverrides(config *ProxiedTraefikConfig, reso
continue
}
// Sort middlewares by priority (highest first)
sort.SliceStable(resource.Middlewares, func(i, j int) bool {
return resource.Middlewares[i].Priority > resource.Middlewares[j].Priority
})
// Build middleware list (mTLS first, then secure headers, then custom headers, then assigned)
var newMiddlewares []string
@ -561,9 +562,26 @@ func (cp *ConfigProxy) applyResourceOverrides(config *ProxiedTraefikConfig, reso
}
}
// Add assigned middlewares
// Build a combined list of internal + external middlewares sorted by priority
type middlewareEntry struct {
Name string
Priority int
}
var allAssigned []middlewareEntry
for _, mw := range resource.Middlewares {
newMiddlewares = append(newMiddlewares, mw.ID)
allAssigned = append(allAssigned, middlewareEntry{Name: mw.ID, Priority: mw.Priority})
}
for _, ext := range resource.ExternalMiddlewares {
allAssigned = append(allAssigned, middlewareEntry{Name: ext.Name, Priority: ext.Priority})
}
// Sort by priority (highest first) for consistent ordering
sort.SliceStable(allAssigned, func(i, j int) bool {
return allAssigned[i].Priority > allAssigned[j].Priority
})
// Add all assigned middlewares (internal by ID, external by name)
for _, entry := range allAssigned {
newMiddlewares = append(newMiddlewares, entry.Name)
}
// Get existing middlewares from router
@ -796,6 +814,30 @@ func (cp *ConfigProxy) fetchResourceData() ([]*resourceData, error) {
return nil, err
}
// Load external (Traefik-native) middleware assignments
extRows, err := cp.db.Query(
"SELECT resource_id, middleware_name, priority FROM resource_external_middlewares ORDER BY resource_id, priority DESC",
)
if err != nil {
log.Printf("Warning: failed to fetch external middlewares: %v", err)
} else {
defer extRows.Close()
for extRows.Next() {
var resID, name string
var priority int
if err := extRows.Scan(&resID, &name, &priority); err != nil {
log.Printf("Failed to scan external middleware: %v", err)
continue
}
if data, ok := resourceMap[resID]; ok {
data.ExternalMiddlewares = append(data.ExternalMiddlewares, externalMiddlewareRef{
Name: name,
Priority: priority,
})
}
}
}
resources := make([]*resourceData, 0, len(resourceMap))
for _, r := range resourceMap {
resources = append(resources, r)

View file

@ -4,6 +4,7 @@ import { useMiddlewareStore } from '@/stores/middlewareStore'
import { useServiceStore } from '@/stores/serviceStore'
import { useMTLSStore } from '@/stores/mtlsStore'
import { useSecurityStore } from '@/stores/securityStore'
import { useTraefikStore } from '@/stores/traefikStore'
import { useAppStore } from '@/stores/appStore'
import { resourceApi } from '@/services/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@ -51,6 +52,7 @@ import {
Save,
X,
Loader2,
Globe,
} from 'lucide-react'
import { parseJSON } from '@/lib/utils'
import type { MTLSWhitelistExternalData } from '@/types'
@ -71,12 +73,15 @@ export function ResourceDetail() {
updateTCPConfig,
updateRouterPriority,
clearError,
assignExternalMiddleware,
removeExternalMiddleware,
} = useResourceStore()
const { middlewares, fetchMiddlewares } = useMiddlewareStore()
const { services, fetchServices } = useServiceStore()
const { config: mtlsConfig, fetchConfig: fetchMTLSConfig } = useMTLSStore()
const { config: securityConfig, fetchConfig: fetchSecurityConfig } = useSecurityStore()
const { httpMiddlewares, fetchMiddlewares: fetchTraefikMiddlewares } = useTraefikStore()
const { updateMTLSConfig, updateMTLSWhitelistConfig } = useResourceStore()
const [selectedMiddlewareId, setSelectedMiddlewareId] = useState('')
@ -84,6 +89,13 @@ export function ResourceDetail() {
const [selectedServiceId, setSelectedServiceId] = useState('')
const [removeMiddlewareModal, setRemoveMiddlewareModal] = useState<string | null>(null)
// External middleware state
const [selectedExternalMw, setSelectedExternalMw] = useState('')
const [customExternalMwName, setCustomExternalMwName] = useState('')
const [externalMwPriority, setExternalMwPriority] = useState('100')
const [removeExternalMwModal, setRemoveExternalMwModal] = useState<string | null>(null)
const [useCustomExternalName, setUseCustomExternalName] = useState(false)
// Edit priority dialog state
const [editPriorityDialog, setEditPriorityDialog] = useState<{ middlewareId: string; currentPriority: number } | null>(null)
const [newPriorityValue, setNewPriorityValue] = useState('')
@ -118,8 +130,9 @@ export function ResourceDetail() {
fetchServices()
fetchMTLSConfig()
fetchSecurityConfig()
fetchTraefikMiddlewares('http')
}
}, [resourceId, fetchResource, fetchMiddlewares, fetchServices, fetchMTLSConfig, fetchSecurityConfig])
}, [resourceId, fetchResource, fetchMiddlewares, fetchServices, fetchMTLSConfig, fetchSecurityConfig, fetchTraefikMiddlewares])
useEffect(() => {
if (!selectedResource) return
@ -173,6 +186,18 @@ export function ResourceDetail() {
}).sort((a, b) => b.priority - a.priority) // Sort by priority descending (higher priority first)
: []
// Parse external middleware assignments from the format "name:priority:provider,..."
const assignedExternalMiddlewares = selectedResource.external_middlewares
? selectedResource.external_middlewares.split(',').filter(Boolean).map(mwStr => {
const parts = mwStr.split(':')
return {
name: parts[0] || '',
priority: parseInt(parts[1] || '100', 10) || 100,
provider: parts[2] || '',
}
}).sort((a, b) => b.priority - a.priority)
: []
const customHeaders = parseJSON<Record<string, string>>(
selectedResource.custom_headers,
{}
@ -208,11 +233,40 @@ export function ResourceDetail() {
}
}
const handleAssignExternalMiddleware = async () => {
const name = useCustomExternalName ? customExternalMwName.trim() : selectedExternalMw
if (!name || !resourceId) return
// Try to find provider info from Traefik data
const traefikMw = httpMiddlewares.find(m => m.name === name)
await assignExternalMiddleware(resourceId, {
middleware_name: name,
priority: parseInt(externalMwPriority, 10) || 100,
provider: traefikMw?.provider || '',
})
setSelectedExternalMw('')
setCustomExternalMwName('')
setExternalMwPriority('100')
}
const handleRemoveExternalMiddleware = async (name: string) => {
if (resourceId) {
await removeExternalMiddleware(resourceId, name)
}
setRemoveExternalMwModal(null)
}
const assignedMiddlewareIds = assignedMiddlewares.map(m => m.id)
const availableMiddlewares = middlewares.filter(
(m) => !assignedMiddlewareIds.includes(m.id)
)
// Filter Traefik middlewares: exclude already-assigned external ones and MW-manager's own
const assignedExternalNames = assignedExternalMiddlewares.map(m => m.name)
const availableExternalMiddlewares = httpMiddlewares.filter(
(m) => !assignedExternalNames.includes(m.name)
)
// Start editing configuration
const handleStartEditConfig = () => {
if (selectedResource) {
@ -1061,6 +1115,132 @@ export function ResourceDetail() {
</CardContent>
</Card>
{/* External / Traefik-Native Middlewares Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
External Middlewares
</CardTitle>
<CardDescription>
{assignedExternalMiddlewares.length} Traefik-native middleware(s) assigned
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Add External Middleware */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium whitespace-nowrap">
{useCustomExternalName ? 'Custom name:' : 'From Traefik:'}
</Label>
<Button
variant="ghost"
size="sm"
onClick={() => {
setUseCustomExternalName(!useCustomExternalName)
setSelectedExternalMw('')
setCustomExternalMwName('')
}}
className="text-xs"
>
{useCustomExternalName ? 'Switch to dropdown' : 'Enter custom name'}
</Button>
</div>
<div className="flex gap-2">
{useCustomExternalName ? (
<Input
placeholder="e.g., my-auth@file or plugin-ratelimit@docker"
value={customExternalMwName}
onChange={(e) => setCustomExternalMwName(e.target.value)}
className="flex-1"
/>
) : (
<Select value={selectedExternalMw} onValueChange={setSelectedExternalMw}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select Traefik middleware" />
</SelectTrigger>
<SelectContent>
{availableExternalMiddlewares.map((mw) => (
<SelectItem key={mw.name} value={mw.name}>
{mw.name} {mw.provider ? `(${mw.provider})` : ''} {mw.type ? `[${mw.type}]` : ''}
</SelectItem>
))}
{availableExternalMiddlewares.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
No external middlewares available
</div>
)}
</SelectContent>
</Select>
)}
<Input
type="number"
placeholder="Priority"
value={externalMwPriority}
onChange={(e) => setExternalMwPriority(e.target.value)}
className="w-24"
/>
<Button
onClick={handleAssignExternalMiddleware}
disabled={useCustomExternalName ? !customExternalMwName.trim() : !selectedExternalMw}
>
<Plus className="h-4 w-4 mr-2" />
Add
</Button>
</div>
</div>
{/* Assigned External Middlewares Table */}
{assignedExternalMiddlewares.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Middleware Name</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Priority</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignedExternalMiddlewares.map((mw, index) => (
<TableRow key={mw.name}>
<TableCell className="text-muted-foreground">{index + 1}</TableCell>
<TableCell className="font-mono text-sm">{mw.name}</TableCell>
<TableCell>
<Badge variant="outline" className="bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300">
{mw.provider || 'external'}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">{mw.priority}</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => setRemoveExternalMwModal(mw.name)}
title="Remove external middleware"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground border rounded-lg">
No external middlewares assigned. Use this section to reference middlewares defined in Traefik&apos;s dynamic config or plugins.
</div>
)}
</CardContent>
</Card>
{/* Custom Headers Card */}
{Object.keys(customHeaders).length > 0 && (
<Card>
@ -1102,6 +1282,17 @@ export function ResourceDetail() {
onConfirm={() => removeMiddlewareModal && handleRemoveMiddleware(removeMiddlewareModal)}
/>
{/* Remove External Middleware Confirmation */}
<ConfirmationModal
open={!!removeExternalMwModal}
onOpenChange={(open) => !open && setRemoveExternalMwModal(null)}
title="Remove External Middleware"
description={`Are you sure you want to remove the external middleware "${removeExternalMwModal}" from this resource?`}
confirmLabel="Remove"
variant="destructive"
onConfirm={() => removeExternalMwModal && handleRemoveExternalMiddleware(removeExternalMwModal)}
/>
{/* Edit Priority Dialog */}
<Dialog
open={!!editPriorityDialog}

View file

@ -11,6 +11,8 @@ import type {
CreateServiceRequest,
UpdateServiceRequest,
AssignMiddlewareRequest,
AssignExternalMiddlewareRequest,
ExternalMiddleware,
AssignServiceRequest,
HTTPConfig,
TLSConfig,
@ -141,6 +143,24 @@ export const resourceApi = {
{ method: 'DELETE' }
),
// External (Traefik-native) middleware assignment
getExternalMiddlewares: (resourceId: string) =>
request<ExternalMiddleware[]>(
`${API_BASE}/resources/${encodeURIComponent(resourceId)}/external-middlewares`
),
assignExternalMiddleware: (resourceId: string, data: AssignExternalMiddlewareRequest) =>
request<void>(`${API_BASE}/resources/${encodeURIComponent(resourceId)}/external-middlewares`, {
method: 'POST',
body: JSON.stringify(data),
}),
removeExternalMiddleware: (resourceId: string, name: string) =>
request<void>(
`${API_BASE}/resources/${encodeURIComponent(resourceId)}/external-middlewares/${encodeURIComponent(name)}`,
{ method: 'DELETE' }
),
// Service assignment
getService: (resourceId: string) =>
request<Service>(`${API_BASE}/resources/${encodeURIComponent(resourceId)}/service`),

View file

@ -3,6 +3,7 @@ import { resourceApi } from '@/services/api'
import type {
Resource,
AssignMiddlewareRequest,
AssignExternalMiddlewareRequest,
HTTPConfig,
TLSConfig,
TCPConfig,
@ -29,6 +30,8 @@ interface ResourceState {
deleteDisabledResources: (ids: string[]) => Promise<boolean>
assignMiddleware: (resourceId: string, data: AssignMiddlewareRequest) => Promise<boolean>
removeMiddleware: (resourceId: string, middlewareId: string) => Promise<boolean>
assignExternalMiddleware: (resourceId: string, data: AssignExternalMiddlewareRequest) => Promise<boolean>
removeExternalMiddleware: (resourceId: string, name: string) => Promise<boolean>
assignService: (resourceId: string, serviceId: string) => Promise<boolean>
removeService: (resourceId: string) => Promise<boolean>
updateHTTPConfig: (resourceId: string, config: HTTPConfig) => Promise<boolean>
@ -149,6 +152,36 @@ export const useResourceStore = create<ResourceState>((set, get) => ({
}
},
// Assign external (Traefik-native) middleware to resource
assignExternalMiddleware: async (resourceId, data) => {
set({ error: null })
try {
await resourceApi.assignExternalMiddleware(resourceId, data)
await get().fetchResource(resourceId)
return true
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to assign external middleware',
})
return false
}
},
// Remove external middleware from resource
removeExternalMiddleware: async (resourceId, name) => {
set({ error: null })
try {
await resourceApi.removeExternalMiddleware(resourceId, name)
await get().fetchResource(resourceId)
return true
} catch (err) {
set({
error: err instanceof Error ? err.message : 'Failed to remove external middleware',
})
return false
}
},
// Assign service to resource
assignService: async (resourceId, serviceId) => {
set({ error: null })

View file

@ -8,6 +8,8 @@ export type {
TCPConfig,
HeadersConfig,
AssignMiddlewareRequest,
AssignExternalMiddlewareRequest,
ExternalMiddleware,
AssignServiceRequest,
MTLSWhitelistConfigRequest,
MTLSWhitelistExternalData,

View file

@ -23,6 +23,7 @@ export interface Resource {
tls_hardening_enabled: boolean
secure_headers_enabled: boolean
middlewares: string
external_middlewares: string
created_at?: string
updated_at?: string
}
@ -80,6 +81,20 @@ export interface AssignMiddlewareRequest {
priority: number
}
export interface AssignExternalMiddlewareRequest {
middleware_name: string
priority: number
provider?: string
}
export interface ExternalMiddleware {
resource_id: string
middleware_name: string
priority: number
provider: string
created_at?: string
}
export interface AssignServiceRequest {
service_id: string
}