Initial commit

This commit is contained in:
hhftechnologies 2025-04-13 22:54:56 +05:30
commit f6e0ce616a
26 changed files with 3945 additions and 0 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
# API connection
PANGOLIN_API_URL=http://pangolin:3001/api/v1
# Traefik configuration
TRAEFIK_CONF_DIR=/conf
# Database
DB_PATH=/data/middleware.db
# Server
PORT=3456
# Logging
LOG_LEVEL=info

65
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,65 @@
name: Build and Push to Docker Hub
# Trigger the workflow on push to main branch or when creating a tag (release)
on:
push:
branches:
- main
- master
tags:
- 'v*'
workflow_dispatch: # Allows manual trigger from GitHub UI
jobs:
build-and-push:
name: Build and Push Docker image
runs-on: ubuntu-latest
steps:
- name: Check out the repo
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: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- 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
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
cache-from: type=gha
cache-to: type=gha,mode=max

48
.gitignore vendored Normal file
View file

@ -0,0 +1,48 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
middleware-manager
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
vendor/
# Go workspace file
go.work
# IDE files
.idea/
.vscode/
*.swp
*.swo
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Node modules and builds
ui/node_modules/
ui/build/
# SQLite database
*.db
# Generated configuration files
/conf/
# Log files
*.log
# Data directories
/data/

98
Dockerfile Normal file
View file

@ -0,0 +1,98 @@
# Build UI stage
FROM node:18-alpine AS ui-builder
WORKDIR /app
# Copy package manifests first from host's ui/src
COPY ui/src/package.json ui/src/package-lock.json* ./
# Install dependencies
RUN npm install
# Create the target directories for source and public files within the container
RUN mkdir src public
# Copy contents of host's ui/public into container's /app/public
# Assumes host has ui/public/index.html etc.
COPY ui/public/ ./public/
# Copy *specific* source files and directories from host's ui/src into container's /app/src
# Add any other top-level .js/.jsx files or necessary subdirectories from ui/src here
COPY ui/src/styles ./src/styles
COPY ui/src/App.js ./src/App.js
COPY ui/src/index.js ./src/index.js
# If you have other files like setupTests.js, reportWebVitals.js etc. directly in ui/src, copy them too:
# COPY ui/src/setupTests.js ./src/setupTests.js
# COPY ui/src/reportWebVitals.js ./src/reportWebVitals.js
# Verify structure (optional)
RUN echo "--- Contents of /app/public ---"
RUN ls -la public
RUN echo "--- Contents of /app/src ---"
RUN ls -la src
# Build the UI (runs in /app, expects ./src, ./public relative to package.json)
RUN npm run build
# Verify build output (optional but good practice)
RUN echo "--- Contents of build ---"
RUN ls -la build/
# Build Go stage
FROM golang:1.19-alpine AS go-builder
# Install build dependencies for Go
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
# Copy go.mod and go.sum files
COPY go.mod go.sum ./
# Download Go dependencies
RUN go mod download
# Copy the Go source code
COPY . .
# Build the Go application
RUN CGO_ENABLED=1 GOOS=linux go build -o middleware-manager .
# Final stage
FROM alpine:3.16
RUN apk add --no-cache ca-certificates sqlite curl tzdata
WORKDIR /app
# Copy the binary from the builder stage
COPY --from=go-builder /app/middleware-manager /app/middleware-manager
# Copy UI build files from UI builder stage
# The build output is in /app/build in the ui-builder stage
COPY --from=ui-builder /app/build /app/ui/build
# Copy configuration files
COPY --from=go-builder /app/config/templates.yaml /app/config/templates.yaml
# Copy database migrations file
COPY --from=go-builder /app/database/migrations.sql /app/database/migrations.sql
# Also copy to root as fallback
COPY --from=go-builder /app/database/migrations.sql /app/migrations.sql
# Create directories for data
RUN mkdir -p /data /conf
# Set environment variables
ENV PANGOLIN_API_URL=http://pangolin:3001/api/v1 \
TRAEFIK_CONF_DIR=/conf \
DB_PATH=/data/middleware.db \
PORT=3456
# Expose the port
EXPOSE 3456
# Run the application
CMD ["/app/middleware-manager"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 HHF Technology
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

66
Makefile Normal file
View file

@ -0,0 +1,66 @@
.PHONY: build build-ui build-backend run clean docker-build docker-push test
# Variables
APP_NAME := middleware-manager
DOCKER_REPO := hhftechnology
DOCKER_TAG := latest
GO_FILES := $(shell find . -name "*.go" -not -path "./vendor/*")
# Default target
all: build
# Build everything
build: build-ui build-backend
# Build UI
build-ui:
@echo "Building UI..."
cd ui && npm install && npm run build
# Build backend
build-backend:
@echo "Building backend..."
go build -o $(APP_NAME) .
# Run the application
run: build
@echo "Running application..."
./$(APP_NAME)
# Clean build artifacts
clean:
@echo "Cleaning..."
rm -f $(APP_NAME)
rm -rf ui/build
# Build Docker image
docker-build: build
@echo "Building Docker image..."
docker build -t $(DOCKER_REPO)/$(APP_NAME):$(DOCKER_TAG) .
# Push Docker image
docker-push: docker-build
@echo "Pushing Docker image..."
docker push $(DOCKER_REPO)/$(APP_NAME):$(DOCKER_TAG)
# Run tests
test:
@echo "Running tests..."
go test -v ./...
# Run the application in development mode
dev:
@echo "Running in development mode..."
go run main.go
# Run the UI in development mode
dev-ui:
@echo "Running UI in development mode..."
cd ui && npm start
# Install dependencies
deps:
@echo "Installing Go dependencies..."
go mod download
@echo "Installing UI dependencies..."
cd ui && npm install

140
README.md Normal file
View file

@ -0,0 +1,140 @@
# Pangolin Middleware Manager
A microservice that allows you to add custom middleware to Pangolin resources without modifying Pangolin itself.
## Overview
Middleware Manager watches for resources created in Pangolin and allows you to attach additional Traefik middlewares such as authentication providers (Authelia, Authentik) to these resources through a simple UI.
## Features
- Automatically synchronizes with Pangolin resources
- Add authentication middlewares to specific resources
- Support for various middleware types (ForwardAuth, BasicAuth, etc.)
- Template library for common middleware configurations
- Web UI for easy management
- Compatible with Authelia, Authentik, and other Traefik-supported authentication providers
## Prerequisites
- A running Pangolin setup with Traefik
- Docker and Docker Compose
## Quick Start
1. Clone this repository:
```
git clone https://github.com/hhftechnology/middleware-manager.git
cd middleware-manager
```
2. Configure the environment:
```
cp .env.example .env
# Edit .env with your specific configuration
```
3. Start the service:
```
docker compose up -d
```
4. Access the UI:
```
http://your-server:3456
```
## Configuration
### Environment Variables
- `PANGOLIN_API_URL`: URL of your Pangolin API (default: `http://pangolin:3001/api/v1`)
- `TRAEFIK_CONF_DIR`: Directory to output Traefik configuration (default: `/conf`)
- `DB_PATH`: Path to SQLite database file (default: `/data/middleware.db`)
- `PORT`: Port to run the API server on (default: `3456`)
### Middleware Templates
Custom middleware templates can be added to `config/templates.yaml`. Several default templates are included:
- Authelia
- Authentik
- Basic Auth
- JWT Auth
- Custom ForwardAuth
## Usage
1. Create resources in Pangolin as usual
2. In the Middleware Manager UI, select a resource
3. Choose a middleware type and configure it
4. Save the configuration
5. The middleware will be automatically applied to the resource
## Docker Compose Integration
Add this service to your existing Pangolin `docker-compose.yml`:
```yaml
services:
middleware-manager:
image: hhftechmology/middleware-manager:latest
container_name: middleware-manager
restart: unless-stopped
networks:
- pangolin
volumes:
- ./config/traefik/conf:/conf
- ./data/middleware:/data
environment:
- PANGOLIN_API_URL=http://pangolin:3001/api/v1
- TRAEFIK_CONF_DIR=/conf
depends_on:
- pangolin
- traefik
ports:
- "3456:3456"
```
## Development
### Prerequisites
- Go 1.19+
- Node.js 16+
- npm or yarn
### Backend Development
```bash
# Run backend in development mode
go run main.go
# Build backend
go build -o middleware-manager
```
### Frontend Development
```bash
cd ui
npm install
npm start
```
### Build Docker Image
```bash
make build
# or
docker build -t middleware-manager .
```
## License
MIT
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.

503
api/handlers.go Normal file
View file

@ -0,0 +1,503 @@
package api
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// APIError represents a standardized error response
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ResponseWithError sends a standardized error response
func ResponseWithError(c *gin.Context, statusCode int, message string) {
c.JSON(statusCode, APIError{
Code: statusCode,
Message: message,
})
}
// getMiddlewares returns all middleware configurations
func (s *Server) getMiddlewares(c *gin.Context) {
middlewares, err := s.db.GetMiddlewares()
if err != nil {
log.Printf("Error fetching middlewares: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to fetch middlewares")
return
}
c.JSON(http.StatusOK, middlewares)
}
// createMiddleware creates a new middleware configuration
func (s *Server) createMiddleware(c *gin.Context) {
var middleware struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Config map[string]interface{} `json:"config" binding:"required"`
}
if err := c.ShouldBindJSON(&middleware); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Validate middleware type
if !isValidMiddlewareType(middleware.Type) {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid middleware type: %s", middleware.Type))
return
}
// Generate a unique ID
id, err := generateID()
if err != nil {
log.Printf("Error generating ID: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to generate ID")
return
}
// Convert config to JSON string
configJSON, err := json.Marshal(middleware.Config)
if err != nil {
log.Printf("Error encoding config: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to encode config")
return
}
// Insert into database 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()
}
}()
_, err = 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)
ResponseWithError(c, http.StatusInternalServerError, "Failed to save middleware")
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.StatusCreated, gin.H{
"id": id,
"name": middleware.Name,
"type": middleware.Type,
"config": middleware.Config,
})
}
// getMiddleware returns a specific middleware configuration
func (s *Server) getMiddleware(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Middleware ID is required")
return
}
middleware, err := s.db.GetMiddleware(id)
if err != nil {
if err.Error() == fmt.Sprintf("middleware not found: %s", id) {
ResponseWithError(c, http.StatusNotFound, "Middleware not found")
return
}
log.Printf("Error fetching middleware: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to fetch middleware")
return
}
c.JSON(http.StatusOK, middleware)
}
// updateMiddleware updates a middleware configuration
func (s *Server) updateMiddleware(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Middleware ID is required")
return
}
var middleware struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Config map[string]interface{} `json:"config" binding:"required"`
}
if err := c.ShouldBindJSON(&middleware); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Validate middleware type
if !isValidMiddlewareType(middleware.Type) {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid middleware type: %s", middleware.Type))
return
}
// Check if middleware exists
var exists int
err := s.db.QueryRow("SELECT 1 FROM middlewares WHERE id = ?", id).Scan(&exists)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Middleware not found")
return
} else if err != nil {
log.Printf("Error checking middleware existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Convert config to JSON string
configJSON, err := json.Marshal(middleware.Config)
if err != nil {
log.Printf("Error encoding config: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to encode config")
return
}
// Update in database 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()
}
}()
_, err = 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)
ResponseWithError(c, http.StatusInternalServerError, "Failed to update middleware")
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{
"id": id,
"name": middleware.Name,
"type": middleware.Type,
"config": middleware.Config,
})
}
// deleteMiddleware deletes a middleware configuration
func (s *Server) deleteMiddleware(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Middleware ID is required")
return
}
// Check for dependencies first
var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM resource_middlewares WHERE middleware_id = ?", id).Scan(&count)
if err != nil {
log.Printf("Error checking middleware dependencies: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
if count > 0 {
ResponseWithError(c, http.StatusConflict, fmt.Sprintf("Cannot delete middleware because it is used by %d resources", count))
return
}
// Delete from database 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 middlewares WHERE id = ?", id)
if err != nil {
log.Printf("Error deleting middleware: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to delete 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, "Middleware 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 deleted successfully"})
}
// getResources returns all resources and their assigned middlewares
func (s *Server) getResources(c *gin.Context) {
resources, err := s.db.GetResources()
if err != nil {
log.Printf("Error fetching resources: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to fetch resources")
return
}
c.JSON(http.StatusOK, resources)
}
// getResource returns a specific resource
func (s *Server) getResource(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
resource, err := s.db.GetResource(id)
if err != nil {
if err.Error() == fmt.Sprintf("resource not found: %s", id) {
ResponseWithError(c, http.StatusNotFound, "Resource not found")
return
}
log.Printf("Error fetching resource: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to fetch resource")
return
}
c.JSON(http.StatusOK, resource)
}
// assignMiddleware assigns a middleware to a resource
func (s *Server) assignMiddleware(c *gin.Context) {
resourceID := c.Param("id")
if resourceID == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
var input struct {
MiddlewareID string `json:"middleware_id" binding:"required"`
Priority int `json:"priority"`
}
if err := c.ShouldBindJSON(&input); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Default priority is 100 if not specified
if input.Priority <= 0 {
input.Priority = 100
}
// Verify resource exists
var exists int
err := s.db.QueryRow("SELECT 1 FROM resources WHERE id = ?", resourceID).Scan(&exists)
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
}
// Verify middleware exists
err = s.db.QueryRow("SELECT 1 FROM middlewares WHERE id = ?", input.MiddlewareID).Scan(&exists)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Middleware not found")
return
} else if err != nil {
log.Printf("Error checking middleware existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Insert or update the resource middleware 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()
}
}()
// First delete any existing relationship
_, err = 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)
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, input.MiddlewareID, input.Priority,
)
if err != nil {
log.Printf("Error assigning middleware: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to assign middleware")
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{
"resource_id": resourceID,
"middleware_id": input.MiddlewareID,
"priority": input.Priority,
})
}
// 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"})
}
// generateID generates a random 16-character hex string
func generateID() (string, error) {
bytes := make([]byte, 8)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return hex.EncodeToString(bytes), nil
}
// 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,
}
return validTypes[typ]
}

231
api/routes.go Normal file
View file

@ -0,0 +1,231 @@
package api
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"github.com/hhftechnology/middleware-manager/database"
)
// Server represents the API server
type Server struct {
db *database.DB
router *gin.Engine
srv *http.Server
}
// ServerConfig contains configuration options for the server
type ServerConfig struct {
Port string
UIPath string
Debug bool
AllowCORS bool
CORSOrigin string
}
// NewServer creates a new API server
func NewServer(db *database.DB, config ServerConfig) *Server {
// Set gin mode based on debug flag
if !config.Debug {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
// Use recovery and logger middleware
router.Use(gin.Recovery())
if config.Debug {
router.Use(gin.Logger())
} else {
// In production, use a custom minimal logger
router.Use(minimalLogger())
}
// CORS middleware if enabled
if config.AllowCORS {
corsConfig := cors.DefaultConfig()
// If a specific origin is provided, use it
if config.CORSOrigin != "" {
corsConfig.AllowOrigins = []string{config.CORSOrigin}
} else {
corsConfig.AllowAllOrigins = true
}
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
corsConfig.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"}
corsConfig.ExposeHeaders = []string{"Content-Length"}
corsConfig.AllowCredentials = true
corsConfig.MaxAge = 12 * time.Hour
router.Use(cors.New(corsConfig))
}
// Setup server
server := &Server{
db: db,
router: router,
srv: &http.Server{
Addr: ":" + config.Port,
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
},
}
// Configure routes
server.setupRoutes(config.UIPath)
return server
}
// setupRoutes configures all the routes for the API server
func (s *Server) setupRoutes(uiPath string) {
// Health check endpoint
s.router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// API routes
api := s.router.Group("/api")
{
// Middleware routes
middlewares := api.Group("/middlewares")
{
middlewares.GET("", s.getMiddlewares)
middlewares.POST("", s.createMiddleware)
middlewares.GET("/:id", s.getMiddleware)
middlewares.PUT("/:id", s.updateMiddleware)
middlewares.DELETE("/:id", s.deleteMiddleware)
}
// Resource routes
resources := api.Group("/resources")
{
resources.GET("", s.getResources)
resources.GET("/:id", s.getResource)
resources.POST("/:id/middlewares", s.assignMiddleware)
resources.DELETE("/:resourceId/middlewares/:middlewareId", s.removeMiddleware)
}
}
// Serve the React app
uiPathToUse := uiPath
if uiPathToUse == "" {
// Default UI path
uiPathToUse = "/app/ui/build"
}
// Check if UI path exists and is a directory
if stat, err := os.Stat(uiPathToUse); err == nil && stat.IsDir() {
s.router.Use(static.Serve("/", static.LocalFile(uiPathToUse, false)))
// Handle all other routes by serving the index.html file
s.router.NoRoute(func(c *gin.Context) {
// API routes should 404 when not found
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
return
}
// Non-API routes serve the SPA
c.File(uiPathToUse + "/index.html")
})
} else {
log.Printf("Warning: UI path %s doesn't exist or is not a directory. Web UI will not be available.", uiPathToUse)
}
}
// Start starts the API server with graceful shutdown
func (s *Server) Start() error {
// Channel to listen for errors coming from the listener.
serverErrors := make(chan error, 1)
// Start the server
go func() {
log.Printf("API server listening on %s", s.srv.Addr)
serverErrors <- s.srv.ListenAndServe()
}()
// Channel to listen for an interrupt or terminate signal from the OS.
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
// Blocking select waiting for either a server error or a signal.
select {
case err := <-serverErrors:
// Non-nil error from ListenAndServe.
return err
case <-shutdown:
log.Println("Shutdown signal received")
// Give outstanding requests a deadline for completion.
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Asking listener to shut down and shed load.
if err := s.srv.Shutdown(ctx); err != nil {
// Error from closing listeners, or context timeout.
log.Printf("Graceful shutdown failed: %v", err)
if err := s.srv.Close(); err != nil {
log.Printf("Error during forced shutdown: %v", err)
}
return err
}
log.Println("API server stopped gracefully")
}
return nil
}
// Stop gracefully stops the API server
func (s *Server) Stop() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := s.srv.Shutdown(ctx); err != nil {
log.Printf("Failed to gracefully shutdown server: %v", err)
if err := s.srv.Close(); err != nil {
log.Printf("Error during forced shutdown: %v", err)
}
} else {
log.Println("API server stopped gracefully")
}
}
// minimalLogger returns a Gin middleware for minimal request logging
func minimalLogger() gin.HandlerFunc {
return func(c *gin.Context) {
// Start timer
start := time.Now()
// Process request
c.Next()
// Log only when path is not being probed by health checkers
if c.Request.URL.Path != "/health" && c.Request.URL.Path != "/ping" {
// Log only requests with errors or non-standard responses
if c.Writer.Status() >= 400 || len(c.Errors) > 0 {
log.Printf("[GIN] %s | %d | %v | %s | %s",
c.Request.Method,
c.Writer.Status(),
time.Since(start),
c.ClientIP(),
c.Request.URL.Path,
)
}
}
}
}

171
config/defaults.go Normal file
View file

@ -0,0 +1,171 @@
package config
import (
"encoding/json"
"io/ioutil"
"log"
"os"
"path/filepath"
"github.com/hhftechnology/middleware-manager/database"
"gopkg.in/yaml.v3"
)
// DefaultMiddleware represents a default middleware template
type DefaultMiddleware struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Type string `yaml:"type"`
Config map[string]interface{} `yaml:"config"`
}
// DefaultTemplates represents the structure of the templates.yaml file
type DefaultTemplates struct {
Middlewares []DefaultMiddleware `yaml:"middlewares"`
}
// LoadDefaultTemplates loads the default middleware templates
func LoadDefaultTemplates(db *database.DB) error {
// Determine the path to the templates file
templatesFile := "config/templates.yaml"
// Check if the file exists in the current directory
if _, err := os.Stat(templatesFile); os.IsNotExist(err) {
// Try to find it in different locations
possiblePaths := []string{
"/app/config/templates.yaml", // Docker container path
"templates.yaml", // Current directory
}
found := false
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
templatesFile = path
found = true
break
}
}
if !found {
log.Printf("Warning: templates.yaml not found, skipping default templates")
return nil
}
}
// Read the templates file
data, err := ioutil.ReadFile(templatesFile)
if err != nil {
return err
}
// Parse the YAML
var templates DefaultTemplates
if err := yaml.Unmarshal(data, &templates); err != nil {
return err
}
// Add templates to the database if they don't exist
for _, middleware := range templates.Middlewares {
// Check if the middleware already exists
var exists int
err := db.QueryRow("SELECT 1 FROM middlewares WHERE id = ?", middleware.ID).Scan(&exists)
if err == nil {
// Middleware exists, skip
continue
}
// Convert config to JSON string
configJSON, err := json.Marshal(middleware.Config)
if err != nil {
log.Printf("Failed to marshal config for %s: %v", middleware.Name, err)
continue
}
// Insert into database
_, err = db.Exec(
"INSERT INTO middlewares (id, name, type, config) VALUES (?, ?, ?, ?)",
middleware.ID, middleware.Name, middleware.Type, string(configJSON),
)
if err != nil {
log.Printf("Failed to insert middleware %s: %v", middleware.Name, err)
continue
}
log.Printf("Added default middleware: %s", middleware.Name)
}
return nil
}
// EnsureConfigDirectory ensures the configuration directory exists
func EnsureConfigDirectory(path string) error {
return os.MkdirAll(path, 0755)
}
// SaveTemplateFile saves the default templates file if it doesn't exist
func SaveTemplateFile(templatesDir string) error {
templatesFile := filepath.Join(templatesDir, "templates.yaml")
// Check if file already exists
if _, err := os.Stat(templatesFile); err == nil {
// File exists, skip
return nil
}
// Create default templates
templates := DefaultTemplates{
Middlewares: []DefaultMiddleware{
{
ID: "authelia",
Name: "Authelia",
Type: "forwardAuth",
Config: map[string]interface{}{
"address": "http://authelia:9091/api/verify?rd=https://auth.yourdomain.com",
"trustForwardHeader": true,
"authResponseHeaders": []string{
"Remote-User",
"Remote-Groups",
"Remote-Name",
"Remote-Email",
},
},
},
{
ID: "authentik",
Name: "Authentik",
Type: "forwardAuth",
Config: map[string]interface{}{
"address": "http://authentik:9000/outpost.goauthentik.io/auth/traefik",
"trustForwardHeader": true,
"authResponseHeaders": []string{
"X-authentik-username",
"X-authentik-groups",
"X-authentik-email",
"X-authentik-name",
"X-authentik-uid",
},
},
},
{
ID: "basic-auth",
Name: "Basic Auth",
Type: "basicAuth",
Config: map[string]interface{}{
"users": []string{
"admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/",
},
},
},
},
}
// Convert to YAML
data, err := yaml.Marshal(templates)
if err != nil {
return err
}
// Write to file
return ioutil.WriteFile(templatesFile, data, 0644)
}

60
config/templates.yaml Normal file
View file

@ -0,0 +1,60 @@
# Default middleware templates
middlewares:
- id: authelia
name: Authelia
type: forwardAuth
config:
address: "http://authelia:9091/api/verify?rd=https://auth.yourdomain.com"
trustForwardHeader: true
authResponseHeaders:
- "Remote-User"
- "Remote-Groups"
- "Remote-Name"
- "Remote-Email"
- id: authentik
name: Authentik
type: forwardAuth
config:
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"
- id: basic-auth
name: Basic Auth
type: basicAuth
config:
users:
- "admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"
- id: jwt-auth
name: JWT Authentication
type: forwardAuth
config:
address: "http://jwt-auth:8080/verify"
trustForwardHeader: true
authResponseHeaders:
- "X-JWT-Sub"
- "X-JWT-Name"
- "X-JWT-Email"
- id: ip-whitelist
name: IP Whitelist
type: ipWhiteList
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
config:
average: 100
burst: 50

277
database/db.go Normal file
View file

@ -0,0 +1,277 @@
package database
import (
"database/sql"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"time"
_ "github.com/mattn/go-sqlite3"
)
// DB is a wrapper around sql.DB
type DB struct {
*sql.DB
}
// InitDB initializes the database connection
func InitDB(dbPath string) (*DB, error) {
// Create parent directory if it doesn't exist
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
}
// Open the database with pragmas for better reliability
db, err := sql.Open("sqlite3", dbPath+"?_journal=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Test the connection
if err := db.Ping(); err != nil {
db.Close() // Close the connection on failure
return nil, fmt.Errorf("failed to ping database: %w", err)
}
// Set connection limits
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
log.Printf("Connected to database at %s", dbPath)
// Run migrations
if err := runMigrations(db); err != nil {
db.Close() // Close the connection on failure
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
return &DB{db}, nil
}
// runMigrations executes the database migrations
func runMigrations(db *sql.DB) error {
// Try to find migrations file in different locations
migrationsFile := findMigrationsFile()
if migrationsFile == "" {
return fmt.Errorf("migrations file not found")
}
// Read migrations file
migrations, err := ioutil.ReadFile(migrationsFile)
if err != nil {
return fmt.Errorf("failed to read migrations file: %w", err)
}
// Execute migrations in a transaction
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
// If something goes wrong, rollback
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Execute migrations
if _, err = tx.Exec(string(migrations)); err != nil {
return fmt.Errorf("failed to execute migrations: %w", err)
}
// Commit the transaction
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
log.Println("Migrations completed successfully")
return nil
}
// findMigrationsFile tries to find the migrations file in different locations
func findMigrationsFile() string {
possiblePaths := []string{
"database/migrations.sql",
"migrations.sql",
"/app/database/migrations.sql",
"/app/migrations.sql",
}
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
// GetMiddlewares fetches all middleware definitions
func (db *DB) GetMiddlewares() ([]map[string]interface{}, error) {
rows, err := db.Query("SELECT id, name, type, config FROM middlewares")
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
var middlewares []map[string]interface{}
for rows.Next() {
var id, name, typ, configStr string
if err := rows.Scan(&id, &name, &typ, &configStr); err != nil {
return nil, fmt.Errorf("row scan failed: %w", err)
}
// Parse the config JSON
var configMap map[string]interface{}
if err := json.Unmarshal([]byte(configStr), &configMap); err != nil {
// If we can't parse the JSON, just return it as a string
middleware := map[string]interface{}{
"id": id,
"name": name,
"type": typ,
"config": configStr,
}
middlewares = append(middlewares, middleware)
continue
}
middleware := map[string]interface{}{
"id": id,
"name": name,
"type": typ,
"config": configMap,
}
middlewares = append(middlewares, middleware)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return middlewares, nil
}
// 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,
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
GROUP BY r.id
`)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
var resources []map[string]interface{}
for rows.Next() {
var id, host, serviceID, orgID, siteID string
var middlewares sql.NullString
if err := rows.Scan(&id, &host, &serviceID, &orgID, &siteID, &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,
}
if middlewares.Valid {
resource["middlewares"] = middlewares.String
} else {
resource["middlewares"] = ""
}
resources = append(resources, resource)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return resources, nil
}
// GetResource fetches a specific resource by ID
func (db *DB) GetResource(id string) (map[string]interface{}, error) {
var host, serviceID, orgID, siteID string
var middlewares sql.NullString
err := db.QueryRow(`
SELECT r.host, r.service_id, r.org_id, r.site_id,
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)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("resource not found: %s", id)
} else if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
resource := map[string]interface{}{
"id": id,
"host": host,
"service_id": serviceID,
"org_id": orgID,
"site_id": siteID,
}
if middlewares.Valid {
resource["middlewares"] = middlewares.String
} else {
resource["middlewares"] = ""
}
return resource, nil
}
// GetMiddleware fetches a specific middleware by ID
func (db *DB) GetMiddleware(id string) (map[string]interface{}, error) {
var name, typ, configStr string
err := db.QueryRow(
"SELECT name, type, config FROM middlewares WHERE id = ?", id,
).Scan(&name, &typ, &configStr)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("middleware not found: %s", id)
} else if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
var configMap map[string]interface{}
if err := json.Unmarshal([]byte(configStr), &configMap); err != nil {
// If we can't parse the JSON, just return the string
return map[string]interface{}{
"id": id,
"name": name,
"type": typ,
"config": configStr,
}, nil
}
return map[string]interface{}{
"id": id,
"name": name,
"type": typ,
"config": configMap,
}, nil
}

37
database/migrations.sql Normal file
View file

@ -0,0 +1,37 @@
-- Middlewares table stores middleware definitions
CREATE TABLE IF NOT EXISTS middlewares (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Resources table stores Pangolin resources
CREATE TABLE IF NOT EXISTS resources (
id TEXT PRIMARY KEY,
host TEXT NOT NULL,
service_id TEXT NOT NULL,
org_id TEXT NOT NULL,
site_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Resource_middlewares table stores the relationship between resources and middlewares
CREATE TABLE IF NOT EXISTS resource_middlewares (
resource_id TEXT NOT NULL,
middleware_id TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 100,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (resource_id, middleware_id),
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE,
FOREIGN KEY (middleware_id) REFERENCES middlewares(id) ON DELETE CASCADE
);
-- 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"]}'),
('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/"]}');

37
docker-compose.yml Normal file
View file

@ -0,0 +1,37 @@
version: '3.8'
services:
middleware-manager:
build:
context: .
dockerfile: Dockerfile
container_name: middleware-manager
restart: unless-stopped
volumes:
- ./data:/data
- ./config/traefik/conf:/conf
environment:
- PANGOLIN_API_URL=http://pangolin:3001/api/v1
- TRAEFIK_CONF_DIR=/conf
- DB_PATH=/data/middleware.db
- PORT=3456
ports:
- "3456:3456"
networks:
- pangolin
# For development/testing, you can include a mock Pangolin API
mock-pangolin:
image: nginx:alpine
container_name: mock-pangolin
volumes:
- ./test/mock-api:/usr/share/nginx/html
ports:
- "3001:80"
networks:
- pangolin
networks:
pangolin:
external: true # In production, this should connect to your existing Pangolin network
# When testing standalone, remove the 'external' flag and it will create a new network

32
go.mod Normal file
View file

@ -0,0 +1,32 @@
module github.com/hhftechnology/middleware-manager
go 1.19
require (
github.com/gin-contrib/cors v1.4.0
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.8.2
github.com/mattn/go-sqlite3 v1.14.16
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.11.1 // indirect
github.com/goccy/go-json v0.10.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/ugorji/go/codec v1.2.8 // indirect
golang.org/x/crypto v0.5.0 // indirect
golang.org/x/net v0.5.0 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/text v0.6.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

130
go.sum Normal file
View file

@ -0,0 +1,130 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U=
github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.8.2 h1:UzKToD9/PoFj/V4rvlKqTRKnQYyz8Sc1MJlv4JHPtvY=
github.com/gin-gonic/gin v1.8.2/go.mod h1:qw5AYuDrzRTnhvusDsrov+fDIxp9Dleuu12h8nfB398=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.8 h1:sgBJS6COt0b/P40VouWKdseidkDgHxYGm0SAglUHfP0=
github.com/ugorji/go/codec v1.2.8/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

163
main.go Normal file
View file

@ -0,0 +1,163 @@
package main
import (
"flag"
"log"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"github.com/hhftechnology/middleware-manager/api"
"github.com/hhftechnology/middleware-manager/config"
"github.com/hhftechnology/middleware-manager/database"
"github.com/hhftechnology/middleware-manager/services"
)
// Configuration represents the application configuration
type Configuration struct {
PangolinAPIURL string
TraefikConfDir string
DBPath string
Port string
UIPath string
CheckInterval time.Duration
GenerateInterval time.Duration
Debug bool
AllowCORS bool
CORSOrigin string
}
func main() {
log.Println("Starting Middleware Manager...")
// Parse command line flags
var debug bool
flag.BoolVar(&debug, "debug", false, "Enable debug mode")
flag.Parse()
// Load configuration
cfg := loadConfiguration(debug)
// Initialize database
db, err := database.InitDB(cfg.DBPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
// Ensure config directory exists
configDir := getEnv("CONFIG_DIR", "/app/config")
if err := config.EnsureConfigDirectory(configDir); err != nil {
log.Printf("Warning: Failed to create config directory: %v", err)
}
// Save default templates file if it doesn't exist
if err := config.SaveTemplateFile(configDir); err != nil {
log.Printf("Warning: Failed to save default templates: %v", err)
}
// Load default middleware templates
if err := config.LoadDefaultTemplates(db); err != nil {
log.Printf("Warning: Failed to load default templates: %v", err)
}
// Create stop channel for graceful shutdown
stopChan := make(chan struct{})
// Start resource watcher
resourceWatcher := services.NewResourceWatcher(db, cfg.PangolinAPIURL)
go resourceWatcher.Start(cfg.CheckInterval)
// Start configuration generator
configGenerator := services.NewConfigGenerator(db, cfg.TraefikConfDir)
go configGenerator.Start(cfg.GenerateInterval)
// Start API server
serverConfig := api.ServerConfig{
Port: cfg.Port,
UIPath: cfg.UIPath,
Debug: cfg.Debug,
AllowCORS: cfg.AllowCORS,
CORSOrigin: cfg.CORSOrigin,
}
server := api.NewServer(db, serverConfig)
go func() {
if err := server.Start(); err != nil {
log.Printf("Server error: %v", err)
close(stopChan)
}
}()
// Wait for shutdown signal or server error
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
select {
case <-signalChan:
log.Println("Received shutdown signal")
case <-stopChan:
log.Println("Received stop signal from server")
}
// Graceful shutdown
log.Println("Shutting down...")
resourceWatcher.Stop()
configGenerator.Stop()
server.Stop()
log.Println("Middleware Manager stopped")
}
// loadConfiguration loads configuration from environment variables
func loadConfiguration(debug bool) Configuration {
// Default check interval is 30 seconds
checkInterval := 30 * time.Second
if intervalStr := getEnv("CHECK_INTERVAL_SECONDS", "30"); intervalStr != "" {
if interval, err := strconv.Atoi(intervalStr); err == nil && interval > 0 {
checkInterval = time.Duration(interval) * time.Second
}
}
// Default generate interval is 10 seconds
generateInterval := 10 * time.Second
if intervalStr := getEnv("GENERATE_INTERVAL_SECONDS", "10"); intervalStr != "" {
if interval, err := strconv.Atoi(intervalStr); err == nil && interval > 0 {
generateInterval = time.Duration(interval) * time.Second
}
}
// Allow CORS if specified
allowCORS := false
if corsStr := getEnv("ALLOW_CORS", "false"); corsStr != "" {
allowCORS = strings.ToLower(corsStr) == "true"
}
// Override debug mode from environment if specified
if debugStr := getEnv("DEBUG", ""); debugStr != "" {
debug = strings.ToLower(debugStr) == "true"
}
return Configuration{
PangolinAPIURL: getEnv("PANGOLIN_API_URL", "http://pangolin:3001/api/v1"),
TraefikConfDir: getEnv("TRAEFIK_CONF_DIR", "/conf"),
DBPath: getEnv("DB_PATH", "/data/middleware.db"),
Port: getEnv("PORT", "3456"),
UIPath: getEnv("UI_PATH", "/app/ui/build"),
CheckInterval: checkInterval,
GenerateInterval: generateInterval,
Debug: debug,
AllowCORS: allowCORS,
CORSOrigin: getEnv("CORS_ORIGIN", ""),
}
}
// getEnv gets an environment variable or returns a default value
func getEnv(key, fallback string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return fallback
}

33
models/middleware.go Normal file
View file

@ -0,0 +1,33 @@
package models
import (
"encoding/json"
"time"
)
// Middleware represents a Traefik middleware configuration
type Middleware struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Config string `json:"config"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ConfigMap returns the middleware config as a map
func (m *Middleware) ConfigMap() (map[string]interface{}, error) {
var config map[string]interface{}
if err := json.Unmarshal([]byte(m.Config), &config); err != nil {
return nil, err
}
return config, nil
}
// ResourceMiddleware represents the relationship between a resource and a middleware
type ResourceMiddleware struct {
ResourceID string `json:"resource_id"`
MiddlewareID string `json:"middleware_id"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
}

54
models/resource.go Normal file
View file

@ -0,0 +1,54 @@
package models
import (
"time"
)
// 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"`
// Middlewares is a list of associated middlewares, populated when needed
Middlewares []ResourceMiddleware `json:"middlewares,omitempty"`
}
// PangolinResource represents the format of a resource from Pangolin API
type PangolinResource struct {
ID string `json:"id"`
Host string `json:"host"`
OrgID string `json:"org_id"`
SiteID string `json:"site_id"`
}
// PangolinTraefikConfig represents the Traefik configuration from Pangolin API
type PangolinTraefikConfig struct {
HTTP struct {
Routers map[string]PangolinRouter `json:"routers"`
Services map[string]PangolinService `json:"services"`
} `json:"http"`
}
// PangolinRouter represents a router configuration from Pangolin API
type PangolinRouter struct {
Rule string `json:"rule"`
Service string `json:"service"`
EntryPoints []string `json:"entryPoints"`
Middlewares []string `json:"middlewares"`
TLS struct {
CertResolver string `json:"certResolver"`
} `json:"tls"`
}
// PangolinService represents a service configuration from Pangolin API
type PangolinService struct {
LoadBalancer struct {
Servers []struct {
URL string `json:"url"`
} `json:"servers"`
} `json:"loadBalancer"`
}

View file

@ -0,0 +1,273 @@
package services
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/hhftechnology/middleware-manager/database"
"gopkg.in/yaml.v3"
)
// ConfigGenerator generates Traefik configuration files
type ConfigGenerator struct {
db *database.DB
confDir string
stopChan chan struct{}
isRunning bool
mutex sync.Mutex // Protects isRunning
}
// TraefikConfig represents the structure of the Traefik configuration
type TraefikConfig struct {
HTTP struct {
Middlewares map[string]interface{} `yaml:"middlewares,omitempty"`
Routers map[string]interface{} `yaml:"routers,omitempty"`
} `yaml:"http"`
}
// NewConfigGenerator creates a new config generator
func NewConfigGenerator(db *database.DB, confDir string) *ConfigGenerator {
return &ConfigGenerator{
db: db,
confDir: confDir,
stopChan: make(chan struct{}),
isRunning: false,
}
}
// Start begins generating configuration files
func (cg *ConfigGenerator) Start(interval time.Duration) {
cg.mutex.Lock()
if cg.isRunning {
cg.mutex.Unlock()
return
}
cg.isRunning = true
cg.mutex.Unlock()
log.Printf("Config generator started, generating every %v", interval)
// Create conf directory if it doesn't exist
if err := os.MkdirAll(cg.confDir, 0755); err != nil {
log.Printf("Failed to create conf directory: %v", err)
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Generate initial configuration
if err := cg.generateConfig(); err != nil {
log.Printf("Initial config generation failed: %v", err)
}
for {
select {
case <-ticker.C:
if err := cg.generateConfig(); err != nil {
log.Printf("Config generation failed: %v", err)
}
case <-cg.stopChan:
log.Println("Config generator stopped")
return
}
}
}
// Stop stops the config generator
func (cg *ConfigGenerator) Stop() {
cg.mutex.Lock()
defer cg.mutex.Unlock()
if !cg.isRunning {
return
}
close(cg.stopChan)
cg.isRunning = false
}
// generateConfig generates Traefik configuration files
func (cg *ConfigGenerator) generateConfig() error {
log.Println("Generating Traefik configuration...")
// Create a new configuration
config := TraefikConfig{}
config.HTTP.Middlewares = make(map[string]interface{})
config.HTTP.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
if err := cg.processResources(&config); err != nil {
return fmt.Errorf("failed to process resources: %w", err)
}
// Write configuration to file
return cg.writeConfigToFile(&config)
}
// processMiddlewares fetches and processes all middleware definitions
func (cg *ConfigGenerator) processMiddlewares(config *TraefikConfig) error {
// Fetch all middlewares
rows, err := cg.db.Query("SELECT id, name, type, config FROM middlewares")
if err != nil {
return fmt.Errorf("failed to fetch middlewares: %w", err)
}
defer rows.Close()
// Process middlewares
for rows.Next() {
var id, name, typ, configStr string
if err := rows.Scan(&id, &name, &typ, &configStr); err != nil {
log.Printf("Failed to scan middleware: %v", err)
continue
}
// Parse middleware config
var middlewareConfig map[string]interface{}
if err := json.Unmarshal([]byte(configStr), &middlewareConfig); err != nil {
log.Printf("Failed to parse middleware config for %s: %v", name, err)
continue
}
// Add middleware to config
config.HTTP.Middlewares[id] = map[string]interface{}{
typ: middlewareConfig,
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("error during middleware rows iteration: %w", err)
}
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)
}
defer rows.Close()
// Group middlewares by resource
resourceMiddlewares := make(map[string][]string)
resourceInfo := make(map[string]struct {
Host string
ServiceID string
})
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
}
resourceMiddlewares[resourceID] = append(resourceMiddlewares[resourceID], middlewareID)
resourceInfo[resourceID] = struct {
Host string
ServiceID string
}{
Host: host,
ServiceID: serviceID,
}
}
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
}
// Add "badger" middleware with http provider suffix if not already present
if !stringSliceContains(middlewares, "badger@http") {
middlewares = append(middlewares, "badger@http")
}
// 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)
}
}
// 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",
},
}
}
return nil
}
// writeConfigToFile writes the configuration to a file
func (cg *ConfigGenerator) writeConfigToFile(config *TraefikConfig) error {
// Create temporary file first to ensure atomic write
configFile := filepath.Join(cg.confDir, "resource-overrides.yml")
tempFile := configFile + ".tmp"
// Convert to YAML
yamlData, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("failed to convert config to YAML: %w", err)
}
// Write to temporary file
if err := os.WriteFile(tempFile, yamlData, 0644); err != nil {
return fmt.Errorf("failed to write temp config file: %w", err)
}
// Rename temp file to final file (atomic operation)
if err := os.Rename(tempFile, configFile); err != nil {
return fmt.Errorf("failed to rename temp config file: %w", err)
}
log.Printf("Generated Traefik configuration at %s", configFile)
return nil
}
// stringSliceContains checks if a string is in a slice
func stringSliceContains(slice []string, str string) bool {
for _, s := range slice {
if s == str {
return true
}
}
return false
}

View file

@ -0,0 +1,230 @@
package services
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/hhftechnology/middleware-manager/database"
"github.com/hhftechnology/middleware-manager/models"
)
// ResourceWatcher watches for resources in Pangolin
type ResourceWatcher struct {
db *database.DB
pangolinAPI string
stopChan chan struct{}
isRunning bool
httpClient *http.Client
}
// NewResourceWatcher creates a new resource watcher
func NewResourceWatcher(db *database.DB, pangolinAPI string) *ResourceWatcher {
// Create HTTP client with timeout
httpClient := &http.Client{
Timeout: 10 * time.Second, // Set reasonable timeout
}
return &ResourceWatcher{
db: db,
pangolinAPI: pangolinAPI,
stopChan: make(chan struct{}),
isRunning: false,
httpClient: httpClient,
}
}
// Start begins watching for resources
func (rw *ResourceWatcher) Start(interval time.Duration) {
if rw.isRunning {
return
}
rw.isRunning = true
log.Printf("Resource watcher started, checking every %v", interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Do an initial check
if err := rw.checkResources(); err != nil {
log.Printf("Initial resource check failed: %v", err)
}
for {
select {
case <-ticker.C:
if err := rw.checkResources(); err != nil {
log.Printf("Resource check failed: %v", err)
}
case <-rw.stopChan:
log.Println("Resource watcher stopped")
return
}
}
}
// Stop stops the resource watcher
func (rw *ResourceWatcher) Stop() {
if !rw.isRunning {
return
}
close(rw.stopChan)
rw.isRunning = false
}
// checkResources fetches resources from Pangolin and updates the database
func (rw *ResourceWatcher) checkResources() error {
log.Println("Checking for resources in Pangolin...")
// Create a context with timeout for the operation
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Fetch Traefik configuration from Pangolin
config, err := rw.fetchTraefikConfig(ctx)
if err != nil {
return fmt.Errorf("failed to fetch Traefik config: %w", err)
}
// Process routers to find resources
for routerID, router := range config.HTTP.Routers {
// Skip non-SSL routers (usually HTTP redirects)
if router.TLS.CertResolver == "" {
continue
}
// Extract host from rule (e.g., "Host(`example.com`)")
host := extractHostFromRule(router.Rule)
if host == "" {
continue
}
// Skip Pangolin's own routers
if isSystemRouter(routerID) {
continue
}
// Get or create the resource
serviceID := router.Service
if err := rw.updateOrCreateResource(routerID, host, serviceID); err != nil {
log.Printf("Error processing resource %s: %v", routerID, err)
// Continue processing other resources even if one fails
continue
}
}
return nil
}
// updateOrCreateResource updates an existing resource or creates a new one
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)
if err == nil {
// Resource exists, update if needed
_, err = rw.db.Exec(
"UPDATE resources SET host = ?, service_id = ?, updated_at = ? WHERE id = ?",
host, serviceID, time.Now(), resourceID,
)
if err != nil {
return fmt.Errorf("failed to update resource %s: %w", resourceID, err)
}
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 (?, ?, ?, ?, ?)",
resourceID, host, serviceID, "unknown", "unknown",
)
if err != nil {
return fmt.Errorf("failed to create resource %s: %w", resourceID, err)
}
log.Printf("Added new resource: %s (%s)", host, resourceID)
return nil
}
// fetchTraefikConfig fetches the Traefik configuration from Pangolin
func (rw *ResourceWatcher) fetchTraefikConfig(ctx context.Context) (*models.PangolinTraefikConfig, error) {
// Build the URL
url := fmt.Sprintf("%s/traefik-config", rw.pangolinAPI)
// Create a request with context
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Make the request
resp, err := rw.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP request returned status %d", resp.StatusCode)
}
// Read response body with a limit to prevent memory issues
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Parse JSON
var config models.PangolinTraefikConfig
if err := json.Unmarshal(body, &config); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
return &config, nil
}
// isSystemRouter checks if a router is a system router (to be skipped)
func isSystemRouter(routerID string) bool {
systemPrefixes := []string{
"api-router",
"next-router",
"ws-router",
}
for _, prefix := range systemPrefixes {
if strings.Contains(routerID, prefix) {
return true
}
}
return false
}
// extractHostFromRule extracts the host from a Traefik rule
// Example: "Host(`example.com`) && PathPrefix(`/api`)" -> "example.com"
func extractHostFromRule(rule string) string {
if !strings.Contains(rule, "Host(`") {
return ""
}
parts := strings.Split(rule, "Host(`")
if len(parts) < 2 {
return ""
}
host := strings.Split(parts[1], "`)")
if len(host) < 1 {
return ""
}
return host[0]
}

13
ui/public/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Middleware Manager</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

1042
ui/src/App.js Normal file

File diff suppressed because it is too large Load diff

12
ui/src/index.js Normal file
View file

@ -0,0 +1,12 @@
// ui/src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/main.css';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

40
ui/src/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "middleware-manager-ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.2.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:3456"
}

155
ui/src/styles/main.css Normal file
View file

@ -0,0 +1,155 @@
/* Base reset and font settings */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.5;
color: #333;
background-color: #f5f7f9;
}
/* Typography basics */
h1, h2, h3, h4, h5, h6 {
margin-bottom: 0.5rem;
font-weight: 600;
line-height: 1.25;
}
p {
margin-bottom: 1rem;
}
/* Links */
a {
color: #1677ff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Button styling */
button, .btn {
cursor: pointer;
border: none;
font-size: 14px;
border-radius: 0.25rem;
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
}
button:disabled, .btn:disabled {
opacity: 0.65;
pointer-events: none;
}
/* Form controls */
input, select, textarea {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
/* Table basics */
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.75rem;
text-align: left;
vertical-align: top;
border-bottom: 1px solid #e2e8f0;
}
th {
font-weight: 600;
}
/* Status indicators */
.status-badge {
display: inline-flex;
align-items: center;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
}
.status-badge.protected {
background-color: #ecfdf5;
color: #047857;
}
.status-badge.not-protected {
background-color: #ffedd5;
color: #c2410c;
}
/* Modal overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
/* Loading animation */
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #1677ff;
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
/* Container utilities */
.container {
width: 100%;
padding-right: 1rem;
padding-left: 1rem;
margin-right: auto;
margin-left: auto;
}
/* Responsive adjustments */
@media (min-width: 640px) {
.container {
max-width: 640px;
}
}
@media (min-width: 768px) {
.container {
max-width: 768px;
}
}
@media (min-width: 1024px) {
.container {
max-width: 1024px;
}
}
@media (min-width: 1280px) {
.container {
max-width: 1280px;
}
}