diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f2aaa9f6f..19851a2c7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,65 +1,109 @@ -name: Build and Push to Docker Hub +name: Build and Push Docker Image -# Trigger the workflow on push to main branch or when creating a tag (release) on: push: branches: - main - master + - dev + paths: + - 'Dockerfile' + - 'go.mod' + - 'go.sum' + - 'main.go' + - 'Makefile' + - '.github/workflows/**' + - 'api/**' + - 'config/**' + - 'database/**' + - 'models/**' + - 'services/**' + - 'ui/**' + - '.env.example' + - '.gitignore' + - 'LICENSE' + - 'README.md' + - 'docker-compose.yml' tags: - 'v*' - workflow_dispatch: # Allows manual trigger from GitHub UI + workflow_dispatch: + +env: + DOCKERHUB_IMAGE_NAME: hhftechnology/middleware-manager jobs: build-and-push: - name: Build and Push Docker image runs-on: ubuntu-latest - steps: - - name: Check out the repo + - name: Checkout Repository uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetch all history for proper versioning - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.23.x' # Match your go.mod version - cache: true - - - name: Verify dependencies - run: go mod verify - + + - name: Debug trigger + run: | + echo "Event: ${{ github.event_name }}" + echo "Ref: ${{ github.ref }}" + echo "Ref name: ${{ github.ref_name }}" + echo "Branch: ${{ github.ref_type == 'branch' && github.ref_name || 'not a branch' }}" + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub + + - name: Login to DockerHub uses: docker/login-action@v3 with: 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 - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/middleware-manager - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=ref,event=branch - type=sha,format=short - latest - - - name: Build and push Docker image + # 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" + 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" + + - name: Build and push uses: docker/build-push-action@v5 with: context: . - file: ./Dockerfile # Points to your Dockerfile push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64 + tags: ${{ steps.docker_tags.outputs.tags }} cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/api/handlers.go b/api/handlers.go index c3be87311..ffea94318 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -321,6 +321,85 @@ func (s *Server) getResource(c *gin.Context) { c.JSON(http.StatusOK, resource) } +// deleteResource deletes a resource from the database +func (s *Server) deleteResource(c *gin.Context) { + id := c.Param("id") + if id == "" { + ResponseWithError(c, http.StatusBadRequest, "Resource ID is required") + return + } + + // Check if resource exists and its status + var status string + err := s.db.QueryRow("SELECT status FROM resources WHERE id = ?", id).Scan(&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 + } + + // Only allow deletion of disabled resources + if status != "disabled" { + ResponseWithError(c, http.StatusBadRequest, "Only disabled resources can be deleted") + return + } + + // Delete the resource using a transaction + tx, err := s.db.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + // If something goes wrong, rollback + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // 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) + 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) + ResponseWithError(c, http.StatusInternalServerError, "Failed to delete resource") + 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, "Resource not found") + return + } + + // Commit the transaction + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Resource deleted successfully"}) +} + // assignMiddleware assigns a middleware to a resource func (s *Server) assignMiddleware(c *gin.Context) { resourceID := c.Param("id") @@ -346,7 +425,8 @@ func (s *Server) assignMiddleware(c *gin.Context) { // Verify resource exists var exists int - err := s.db.QueryRow("SELECT 1 FROM resources WHERE id = ?", resourceID).Scan(&exists) + var status string + err := s.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 @@ -355,6 +435,12 @@ func (s *Server) assignMiddleware(c *gin.Context) { ResponseWithError(c, http.StatusInternalServerError, "Database error") return } + + // Don't allow attaching middlewares to disabled resources + if status == "disabled" { + ResponseWithError(c, http.StatusBadRequest, "Cannot assign middleware to a disabled resource") + return + } // Verify middleware exists err = s.db.QueryRow("SELECT 1 FROM middlewares WHERE id = ?", input.MiddlewareID).Scan(&exists) @@ -418,62 +504,178 @@ func (s *Server) assignMiddleware(c *gin.Context) { }) } +// assignMultipleMiddlewares assigns multiple middlewares to a resource in one operation +func (s *Server) assignMultipleMiddlewares(c *gin.Context) { + resourceID := c.Param("id") + if resourceID == "" { + ResponseWithError(c, http.StatusBadRequest, "Resource ID is required") + return + } + + var input struct { + Middlewares []struct { + MiddlewareID string `json:"middleware_id" binding:"required"` + Priority int `json:"priority"` + } `json:"middlewares" 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 = ?", 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 + } + + // Don't allow attaching middlewares to disabled resources + if status == "disabled" { + ResponseWithError(c, http.StatusBadRequest, "Cannot assign middlewares to a disabled resource") + return + } + + // Start a transaction + tx, err := s.db.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + // If something goes wrong, rollback + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Process each middleware + successful := make([]map[string]interface{}, 0) + for _, mw := range input.Middlewares { + // Default priority is 100 if not specified + if mw.Priority <= 0 { + mw.Priority = 100 + } + + // Verify middleware exists + var middlewareExists int + err := s.db.QueryRow("SELECT 1 FROM middlewares WHERE id = ?", mw.MiddlewareID).Scan(&middlewareExists) + if err == sql.ErrNoRows { + // Skip this middleware but don't fail the entire request + log.Printf("Middleware %s not found, skipping", mw.MiddlewareID) + continue + } else if err != nil { + log.Printf("Error checking middleware existence: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + // First delete any existing relationship + _, err = 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) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + // Then insert the new relationship + _, err = 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) + ResponseWithError(c, http.StatusInternalServerError, "Failed to assign middleware") + return + } + + successful = append(successful, map[string]interface{}{ + "middleware_id": mw.MiddlewareID, + "priority": mw.Priority, + }) + } + + // Commit the transaction + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + c.JSON(http.StatusOK, gin.H{ + "resource_id": resourceID, + "middlewares": successful, + }) +} + // removeMiddleware removes a middleware from a resource func (s *Server) removeMiddleware(c *gin.Context) { - resourceID := c.Param("resourceId") - middlewareID := c.Param("middlewareId") - - if resourceID == "" || middlewareID == "" { - ResponseWithError(c, http.StatusBadRequest, "Resource ID and Middleware ID are required") - return - } - - // Delete the relationship using a transaction - tx, err := s.db.Begin() - if err != nil { - log.Printf("Error beginning transaction: %v", err) - ResponseWithError(c, http.StatusInternalServerError, "Database error") - return - } - - // If something goes wrong, rollback - defer func() { - if err != nil { - tx.Rollback() - } - }() - - result, err := tx.Exec( - "DELETE FROM resource_middlewares WHERE resource_id = ? AND middleware_id = ?", - resourceID, middlewareID, - ) - - if err != nil { - log.Printf("Error removing middleware: %v", err) - ResponseWithError(c, http.StatusInternalServerError, "Failed to remove 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, "Resource middleware relationship not found") - return - } - - // Commit the transaction - if err = tx.Commit(); err != nil { - log.Printf("Error committing transaction: %v", err) - ResponseWithError(c, http.StatusInternalServerError, "Database error") - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Middleware removed from resource successfully"}) + resourceID := c.Param("id") + middlewareID := c.Param("middlewareId") + + if resourceID == "" || middlewareID == "" { + ResponseWithError(c, http.StatusBadRequest, "Resource ID and Middleware ID are required") + return + } + + // Delete the relationship using a transaction + tx, err := s.db.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + // If something goes wrong, rollback + defer func() { + if err != nil { + tx.Rollback() + } + }() + + result, err := tx.Exec( + "DELETE FROM resource_middlewares WHERE resource_id = ? AND middleware_id = ?", + resourceID, middlewareID, + ) + + if err != nil { + log.Printf("Error removing middleware: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Failed to remove 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, "Resource middleware relationship not found") + return + } + + // Commit the transaction + if err = tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Middleware removed from resource successfully"}) } // generateID generates a random 16-character hex string @@ -488,15 +690,17 @@ func generateID() (string, error) { // isValidMiddlewareType checks if a middleware type is valid func isValidMiddlewareType(typ string) bool { validTypes := map[string]bool{ - "basicAuth": true, - "forwardAuth": true, - "ipWhiteList": true, - "rateLimit": true, - "headers": true, - "stripPrefix": true, - "addPrefix": true, - "redirectRegex": true, - "redirectScheme": true, + "basicAuth": true, + "forwardAuth": true, + "ipWhiteList": true, + "rateLimit": true, + "headers": true, + "stripPrefix": true, + "addPrefix": true, + "redirectRegex": true, + "redirectScheme": true, + "chain": true, + "replacepathregex": true, } return validTypes[typ] diff --git a/api/routes.go b/api/routes.go index d2eeaed0f..67f658112 100644 --- a/api/routes.go +++ b/api/routes.go @@ -114,8 +114,10 @@ func (s *Server) setupRoutes(uiPath string) { { resources.GET("", s.getResources) resources.GET("/:id", s.getResource) + resources.DELETE("/:id", s.deleteResource) resources.POST("/:id/middlewares", s.assignMiddleware) - resources.DELETE("/:resourceId/middlewares/:middlewareId", s.removeMiddleware) + resources.POST("/:id/middlewares/bulk", s.assignMultipleMiddlewares) // New endpoint for bulk assignment + resources.DELETE("/:id/middlewares/:middlewareId", s.removeMiddleware) } } diff --git a/config/templates.yaml b/config/templates.yaml index 9bbe0e3fd..637535bfe 100644 --- a/config/templates.yaml +++ b/config/templates.yaml @@ -57,4 +57,45 @@ middlewares: type: rateLimit config: average: 100 - burst: 50 \ No newline at end of file + 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 + config: + accessControlAllowMethods: + - GET + - OPTIONS + - PUT + browserXssFilter: true + contentTypeNosniff: true + customFrameOptionsValue: SAMEORIGIN + customResponseHeaders: + X-Forwarded-Proto: https + X-Robots-Tag: none,noarchive,nosnippet,notranslate,noimageindex + server: "" + forceSTSHeader: true + hostsProxyHeaders: + - X-Forwarded-Host + permissionsPolicy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), vr=() + referrerPolicy: same-origin + sslProxyHeaders: + X-Forwarded-Proto: https + stsIncludeSubdomains: true + stsPreload: true + stsSeconds: 63072000 + + - id: nextcloud-dav + name: Nextcloud WebDAV Redirect + type: replacepathregex + config: + regex: "^/.well-known/ca(l|rd)dav" + replacement: "/remote.php/dav/" \ No newline at end of file diff --git a/database/db.go b/database/db.go index 610834364..7934ed203 100644 --- a/database/db.go +++ b/database/db.go @@ -50,6 +50,11 @@ func InitDB(dbPath string) (*DB, error) { db.Close() // Close the connection on failure return nil, fmt.Errorf("failed to run migrations: %w", err) } + + // Run post-migration updates + if err := runPostMigrationUpdates(db); err != nil { + log.Printf("Warning: Error running post-migration updates: %v", err) + } return &DB{db}, nil } @@ -95,6 +100,32 @@ func runMigrations(db *sql.DB) error { return nil } +// 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 + err := db.QueryRow(` + SELECT COUNT(*) > 0 + FROM pragma_table_info('resources') + WHERE name = 'status' + `).Scan(&hasStatusColumn) + + if err != nil { + return fmt.Errorf("failed to check if status 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) + } + } + + return nil +} + // findMigrationsFile tries to find the migrations file in different locations func findMigrationsFile() string { possiblePaths := []string{ @@ -161,7 +192,7 @@ 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, + SELECT r.id, r.host, r.service_id, r.org_id, r.site_id, r.status, GROUP_CONCAT(m.id || ':' || m.name || ':' || rm.priority, ',') as middlewares FROM resources r LEFT JOIN resource_middlewares rm ON r.id = rm.resource_id @@ -175,9 +206,9 @@ func (db *DB) GetResources() ([]map[string]interface{}, error) { var resources []map[string]interface{} for rows.Next() { - var id, host, serviceID, orgID, siteID string + var id, host, serviceID, orgID, siteID, status string var middlewares sql.NullString - if err := rows.Scan(&id, &host, &serviceID, &orgID, &siteID, &middlewares); err != nil { + if err := rows.Scan(&id, &host, &serviceID, &orgID, &siteID, &status, &middlewares); err != nil { return nil, fmt.Errorf("row scan failed: %w", err) } @@ -187,6 +218,7 @@ func (db *DB) GetResources() ([]map[string]interface{}, error) { "service_id": serviceID, "org_id": orgID, "site_id": siteID, + "status": status, } if middlewares.Valid { @@ -207,18 +239,18 @@ 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 string + var host, serviceID, orgID, siteID, status string var middlewares sql.NullString err := db.QueryRow(` - SELECT r.host, r.service_id, r.org_id, r.site_id, + SELECT r.host, r.service_id, r.org_id, r.site_id, r.status, 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, &middlewares) + `, id).Scan(&host, &serviceID, &orgID, &siteID, &status, &middlewares) if err == sql.ErrNoRows { return nil, fmt.Errorf("resource not found: %s", id) @@ -232,6 +264,7 @@ func (db *DB) GetResource(id string) (map[string]interface{}, error) { "service_id": serviceID, "org_id": orgID, "site_id": siteID, + "status": status, } if middlewares.Valid { diff --git a/database/migrations.sql b/database/migrations.sql index 6e724db64..fcb6679a7 100644 --- a/database/migrations.sql +++ b/database/migrations.sql @@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS resources ( service_id TEXT NOT NULL, org_id TEXT NOT NULL, site_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -34,4 +35,8 @@ CREATE TABLE IF NOT EXISTS resource_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"]}'), ('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/"]}'); \ No newline at end of file +('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 diff --git a/services/config_generator.go b/services/config_generator.go index ff9903543..3204812ce 100644 --- a/services/config_generator.go +++ b/services/config_generator.go @@ -140,6 +140,22 @@ 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 + } + } + // Add middleware to config config.HTTP.Middlewares[id] = map[string]interface{}{ typ: middlewareConfig, diff --git a/services/resource_watcher.go b/services/resource_watcher.go index de9fe62cd..205d34a59 100644 --- a/services/resource_watcher.go +++ b/services/resource_watcher.go @@ -93,6 +93,26 @@ func (rw *ResourceWatcher) checkResources() error { return fmt.Errorf("failed to fetch Traefik config: %w", err) } + // Get all existing resources from the database + var existingResources []string + rows, err := rw.db.Query("SELECT id FROM resources WHERE status = 'active'") + if err != nil { + return fmt.Errorf("failed to query existing resources: %w", err) + } + + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + log.Printf("Error scanning resource ID: %v", err) + continue + } + existingResources = append(existingResources, id) + } + rows.Close() + + // Keep track of resources we find in Pangolin + foundResources := make(map[string]bool) + // Process routers to find resources for routerID, router := range config.HTTP.Routers { // Skip non-SSL routers (usually HTTP redirects) @@ -119,6 +139,23 @@ func (rw *ResourceWatcher) checkResources() error { // Continue processing other resources even if one fails continue } + + // Mark this resource as found + foundResources[routerID] = true + } + + // Mark resources as disabled if they no longer exist in Pangolin + for _, resourceID := range existingResources { + if !foundResources[resourceID] { + log.Printf("Resource %s no longer exists in Pangolin, marking as disabled", resourceID) + _, err := rw.db.Exec( + "UPDATE resources SET status = 'disabled', updated_at = ? WHERE id = ?", + time.Now(), resourceID, + ) + if err != nil { + log.Printf("Error marking resource as disabled: %v", err) + } + } } return nil @@ -128,22 +165,28 @@ func (rw *ResourceWatcher) checkResources() error { func (rw *ResourceWatcher) updateOrCreateResource(resourceID, host, serviceID string) error { // Check if resource already exists var exists int - err := rw.db.QueryRow("SELECT 1 FROM resources WHERE id = ?", resourceID).Scan(&exists) + var status string + err := rw.db.QueryRow("SELECT 1, status FROM resources WHERE id = ?", resourceID).Scan(&exists, &status) if err == nil { - // Resource exists, update if needed + // Resource exists, update if needed and ensure status is active _, err = rw.db.Exec( - "UPDATE resources SET host = ?, service_id = ?, updated_at = ? WHERE id = ?", + "UPDATE resources SET host = ?, service_id = ?, status = 'active', updated_at = ? WHERE id = ?", host, serviceID, time.Now(), resourceID, ) if err != nil { return fmt.Errorf("failed to update resource %s: %w", resourceID, err) } + + if status == "disabled" { + log.Printf("Resource %s was disabled but is now active again", resourceID) + } + 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) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO resources (id, host, service_id, org_id, site_id, status) VALUES (?, ?, ?, ?, ?, 'active')", resourceID, host, serviceID, "unknown", "unknown", ) if err != nil { diff --git a/ui/src/App.js b/ui/src/App.js index d2933fd00..c42779c35 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -7,11 +7,19 @@ const api = { // Resources getResources: () => fetch(`${API_URL}/resources`).then(res => res.json()), getResource: (id) => fetch(`${API_URL}/resources/${id}`).then(res => res.json()), + deleteResource: (id) => fetch(`${API_URL}/resources/${id}`, { + method: 'DELETE' + }).then(res => res.json()), assignMiddleware: (resourceId, data) => fetch(`${API_URL}/resources/${resourceId}/middlewares`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }).then(res => res.json()), + assignMultipleMiddlewares: (resourceId, data) => fetch(`${API_URL}/resources/${resourceId}/middlewares/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }).then(res => res.json()), removeMiddleware: (resourceId, middlewareId) => fetch(`${API_URL}/resources/${resourceId}/middlewares/${middlewareId}`, { method: 'DELETE' }).then(res => res.json()), @@ -46,6 +54,54 @@ const parseMiddlewares = (middlewaresStr) => { }); }; +// Helper function to format middleware chains for display +const formatMiddlewareDisplay = (middleware, allMiddlewares) => { + // Check if this is a chain middleware + const isChain = middleware.type === 'chain'; + + // Get the config object + let configObj = middleware.config; + if (typeof configObj === 'string') { + try { + configObj = JSON.parse(configObj); + } catch (e) { + console.error('Error parsing middleware config:', e); + configObj = {}; + } + } + + return ( +
+
+ {middleware.name} + + {middleware.type} + + {isChain && (Middleware Chain)} +
+ + {/* Display chained middlewares if this is a chain */} + {isChain && configObj.middlewares && configObj.middlewares.length > 0 && ( +
+
Chain contains:
+ +
+ )} +
+ ); +}; + // Main App Component const App = () => { const [page, setPage] = useState('dashboard'); @@ -163,8 +219,10 @@ const Dashboard = ({ navigateTo }) => { } // Calculate stats - const protectedResources = resources.filter(r => r.middlewares && r.middlewares.length > 0).length; - const unprotectedResources = resources.length - protectedResources; + const protectedResources = resources.filter(r => r.status !== 'disabled' && r.middlewares && r.middlewares.length > 0).length; + const activeResources = resources.filter(r => r.status !== 'disabled').length; + const disabledResources = resources.filter(r => r.status === 'disabled').length; + const unprotectedResources = activeResources - protectedResources; return (
@@ -174,7 +232,12 @@ const Dashboard = ({ navigateTo }) => {

Resources

-

{resources.length}

+

{activeResources}

+ {disabledResources > 0 && ( +

+ {disabledResources} disabled resources +

+ )}

Middlewares

@@ -182,7 +245,7 @@ const Dashboard = ({ navigateTo }) => {

Protected Resources

-

{protectedResources} / {resources.length}

+

{protectedResources} / {activeResources}

@@ -212,23 +275,54 @@ const Dashboard = ({ navigateTo }) => { {resources.slice(0, 5).map(resource => { const middlewaresList = parseMiddlewares(resource.middlewares); const isProtected = middlewaresList.length > 0; + const isDisabled = resource.status === 'disabled'; return ( - - {resource.host} + + + {resource.host} + {isDisabled && ( + + Removed from Pangolin + + )} + - - {isProtected ? 'Protected' : 'Not Protected'} + + {isDisabled ? 'Disabled' : isProtected ? 'Protected' : 'Not Protected'} {middlewaresList.length > 0 ? middlewaresList.length : 'None'} + {isDisabled && ( + + )} ); @@ -254,7 +348,25 @@ const Dashboard = ({ navigateTo }) => {

- You have {unprotectedResources} resources that are not protected with any middleware. + You have {unprotectedResources} active resources that are not protected with any middleware. +

+
+ + + )} + + {/* Warning for disabled resources */} + {disabledResources > 0 && ( +
+
+
+ + + +
+
+

+ You have {disabledResources} disabled resources that were removed from Pangolin. navigateTo('resources')}>View all resources to delete them.

@@ -289,6 +401,22 @@ const ResourcesList = ({ navigateTo }) => { fetchResources(); }, []); + const handleDeleteResource = async (id, host) => { + // eslint-disable-next-line no-restricted-globals + if (!confirm(`Are you sure you want to delete the resource "${host}"? This cannot be undone.`)) { + return; + } + + try { + await api.deleteResource(id); + alert('Resource deleted successfully'); + fetchResources(); + } catch (err) { + alert(`Failed to delete resource: ${err.message || 'Unknown error'}`); + console.error(err); + } + }; + const filteredResources = resources.filter(resource => resource.host.toLowerCase().includes(searchTerm.toLowerCase()) ); @@ -335,23 +463,42 @@ const ResourcesList = ({ navigateTo }) => { {filteredResources.map(resource => { const middlewaresList = parseMiddlewares(resource.middlewares); const isProtected = middlewaresList.length > 0; + const isDisabled = resource.status === 'disabled'; return ( - - {resource.host} + + + {resource.host} + {isDisabled && ( + + Removed from Pangolin + + )} + - - {isProtected ? 'Protected' : 'Not Protected'} + + {isDisabled ? 'Disabled' : isProtected ? 'Protected' : 'Not Protected'} {middlewaresList.length > 0 ? middlewaresList.length : 'None'} - + + {isDisabled && ( + + )} ); @@ -378,7 +525,7 @@ const ResourceDetail = ({ id, navigateTo }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showModal, setShowModal] = useState(false); - const [selectedMiddleware, setSelectedMiddleware] = useState(''); + const [selectedMiddlewares, setSelectedMiddlewares] = useState([]); const [priority, setPriority] = useState(100); const fetchData = async () => { @@ -413,24 +560,42 @@ const ResourceDetail = ({ id, navigateTo }) => { fetchData(); }, [id]); + const handleMiddlewareSelection = (e) => { + const options = e.target.options; + const selected = []; + for (let i = 0; i < options.length; i++) { + if (options[i].selected) { + selected.push(options[i].value); + } + } + setSelectedMiddlewares(selected); + }; + const handleAssignMiddleware = async (e) => { e.preventDefault(); - if (!selectedMiddleware) return; + if (selectedMiddlewares.length === 0) { + alert('Please select at least one middleware'); + return; + } try { - await api.assignMiddleware(id, { - middleware_id: selectedMiddleware, + const middlewaresToAdd = selectedMiddlewares.map(middlewareId => ({ + middleware_id: middlewareId, priority: parseInt(priority) + })); + + await api.assignMultipleMiddlewares(id, { + middlewares: middlewaresToAdd }); setShowModal(false); - setSelectedMiddleware(''); + setSelectedMiddlewares([]); setPriority(100); // Refresh data fetchData(); } catch (err) { - alert('Failed to assign middleware'); + alert('Failed to assign middlewares'); console.error(err); } }; @@ -480,6 +645,8 @@ const ResourceDetail = ({ id, navigateTo }) => { ); } + const isDisabled = resource.status === 'disabled'; + return (
@@ -490,8 +657,57 @@ const ResourceDetail = ({ id, navigateTo }) => { Back

Resource: {resource.host}

+ {isDisabled && ( + + Removed from Pangolin + + )}
+ {/* Warning for disabled resources */} + {isDisabled && ( +
+
+
+ + + +
+
+

+ This resource has been removed from Pangolin and is now disabled. Any changes to middleware will not take effect. +

+
+ + +
+
+
+
+ )} + {/* Resource details */}

Resource Details

@@ -517,8 +733,11 @@ const ResourceDetail = ({ id, navigateTo }) => {

Status

- 0 ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}> - {assignedMiddlewares.length > 0 ? 'Protected' : 'Not Protected'} + 0 ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800' + }`}> + {isDisabled ? 'Disabled' : assignedMiddlewares.length > 0 ? 'Protected' : 'Not Protected'}

@@ -535,7 +754,7 @@ const ResourceDetail = ({ id, navigateTo }) => {

Attached Middlewares