chore: defer Sourcery auth; continue work
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
CPM_ENV=development
|
||||
CPM_HTTP_PORT=8080
|
||||
CPM_DB_PATH=./data/cpm.db
|
||||
CPM_CADDY_ADMIN_API=http://localhost:2019
|
||||
CPM_CADDY_CONFIG_DIR=./data/caddy
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/routes"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/database"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/server"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Printf("starting %s backend on version %s", version.Name, version.Full())
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.Connect(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
log.Fatalf("connect database: %v", err)
|
||||
}
|
||||
|
||||
router := server.NewRouter(cfg.FrontendDir)
|
||||
|
||||
if err := routes.Register(router, db); err != nil {
|
||||
log.Fatalf("register routes: %v", err)
|
||||
}
|
||||
|
||||
// Register import handler with config dependencies
|
||||
routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir)
|
||||
|
||||
// Check for mounted Caddyfile on startup
|
||||
if err := handlers.CheckMountedImport(db, cfg.ImportCaddyfile, cfg.CaddyBinary, cfg.ImportDir); err != nil {
|
||||
log.Printf("WARNING: failed to process mounted Caddyfile: %v", err)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%s", cfg.HTTPPort)
|
||||
log.Printf("starting %s backend on %s", version.Name, addr)
|
||||
|
||||
if err := router.Run(addr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
module github.com/Wikid82/CaddyProxyManagerPlus/backend
|
||||
|
||||
go 1.24.4
|
||||
@@ -0,0 +1,285 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
// ImportHandler handles Caddyfile import operations.
|
||||
type ImportHandler struct {
|
||||
db *gorm.DB
|
||||
proxyHostSvc *services.ProxyHostService
|
||||
importerservice *caddy.Importer
|
||||
importDir string
|
||||
}
|
||||
|
||||
// NewImportHandler creates a new import handler.
|
||||
func NewImportHandler(db *gorm.DB, caddyBinary, importDir string) *ImportHandler {
|
||||
return &ImportHandler{
|
||||
db: db,
|
||||
proxyHostSvc: services.NewProxyHostService(db),
|
||||
importerservice: caddy.NewImporter(caddyBinary),
|
||||
importDir: importDir,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers import-related routes.
|
||||
func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/import/status", h.GetStatus)
|
||||
router.GET("/import/preview", h.GetPreview)
|
||||
router.POST("/import/upload", h.Upload)
|
||||
router.POST("/import/commit", h.Commit)
|
||||
router.DELETE("/import/cancel", h.Cancel)
|
||||
}
|
||||
|
||||
// GetStatus returns current import session status.
|
||||
func (h *ImportHandler) GetStatus(c *gin.Context) {
|
||||
var session models.ImportSession
|
||||
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
||||
Order("created_at DESC").
|
||||
First(&session).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"has_pending": false})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"has_pending": true,
|
||||
"session": session,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPreview returns parsed hosts and conflicts for review.
|
||||
func (h *ImportHandler) GetPreview(c *gin.Context) {
|
||||
var session models.ImportSession
|
||||
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
||||
Order("created_at DESC").
|
||||
First(&session).Error
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
|
||||
return
|
||||
}
|
||||
|
||||
var result caddy.ImportResult
|
||||
if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update status to reviewing
|
||||
session.Status = "reviewing"
|
||||
h.db.Save(&session)
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// Upload handles manual Caddyfile upload or paste.
|
||||
func (h *ImportHandler) Upload(c *gin.Context) {
|
||||
var req struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
tempPath := filepath.Join(h.importDir, fmt.Sprintf("upload-%s.caddyfile", uuid.NewString()))
|
||||
if err := os.MkdirAll(h.importDir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create import directory"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
|
||||
return
|
||||
}
|
||||
|
||||
// Process the uploaded file
|
||||
if err := h.processImport(tempPath, req.Filename); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "upload processed, ready for review"})
|
||||
}
|
||||
|
||||
// Commit finalizes the import with user's conflict resolutions.
|
||||
func (h *ImportHandler) Commit(c *gin.Context) {
|
||||
var req struct {
|
||||
SessionUUID string `json:"session_uuid" binding:"required"`
|
||||
Resolutions map[string]string `json:"resolutions"` // domain -> action (skip, rename, merge)
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var session models.ImportSession
|
||||
if err := h.db.Where("uuid = ? AND status = ?", req.SessionUUID, "reviewing").First(&session).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or not in reviewing state"})
|
||||
return
|
||||
}
|
||||
|
||||
var result caddy.ImportResult
|
||||
if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert parsed hosts to ProxyHost models
|
||||
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
|
||||
|
||||
created := 0
|
||||
skipped := 0
|
||||
errors := []string{}
|
||||
|
||||
for _, host := range proxyHosts {
|
||||
action := req.Resolutions[host.Domain]
|
||||
|
||||
if action == "skip" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if action == "rename" {
|
||||
host.Domain = host.Domain + "-imported"
|
||||
}
|
||||
|
||||
host.UUID = uuid.NewString()
|
||||
|
||||
if err := h.proxyHostSvc.Create(&host); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", host.Domain, err.Error()))
|
||||
} else {
|
||||
created++
|
||||
}
|
||||
}
|
||||
|
||||
// Mark session as committed
|
||||
now := time.Now()
|
||||
session.Status = "committed"
|
||||
session.CommittedAt = &now
|
||||
session.UserResolutions = string(mustMarshal(req.Resolutions))
|
||||
h.db.Save(&session)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"created": created,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
})
|
||||
}
|
||||
|
||||
// Cancel discards a pending import session.
|
||||
func (h *ImportHandler) Cancel(c *gin.Context) {
|
||||
sessionUUID := c.Query("session_uuid")
|
||||
if sessionUUID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
|
||||
return
|
||||
}
|
||||
|
||||
var session models.ImportSession
|
||||
if err := h.db.Where("uuid = ?", sessionUUID).First(&session).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
session.Status = "rejected"
|
||||
h.db.Save(&session)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
|
||||
}
|
||||
|
||||
// processImport handles the import logic for both mounted and uploaded files.
|
||||
func (h *ImportHandler) processImport(caddyfilePath, originalName string) error {
|
||||
// Validate Caddy binary
|
||||
if err := h.importerservice.ValidateCaddyBinary(); err != nil {
|
||||
return fmt.Errorf("caddy binary not available: %w", err)
|
||||
}
|
||||
|
||||
// Parse and extract hosts
|
||||
result, err := h.importerservice.ImportFile(caddyfilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("import failed: %w", err)
|
||||
}
|
||||
|
||||
// Check for conflicts with existing hosts
|
||||
existingHosts, _ := h.proxyHostSvc.List()
|
||||
existingDomains := make(map[string]bool)
|
||||
for _, host := range existingHosts {
|
||||
existingDomains[host.Domain] = true
|
||||
}
|
||||
|
||||
for _, parsed := range result.Hosts {
|
||||
if existingDomains[parsed.Domain] {
|
||||
result.Conflicts = append(result.Conflicts,
|
||||
fmt.Sprintf("Domain '%s' already exists in CPM+", parsed.Domain))
|
||||
}
|
||||
}
|
||||
|
||||
// Create import session
|
||||
session := models.ImportSession{
|
||||
UUID: uuid.NewString(),
|
||||
SourceFile: originalName,
|
||||
Status: "pending",
|
||||
ParsedData: string(mustMarshal(result)),
|
||||
ConflictReport: string(mustMarshal(result.Conflicts)),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&session).Error; err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
// Backup original file
|
||||
if _, err := caddy.BackupCaddyfile(caddyfilePath, filepath.Join(h.importDir, "backups")); err != nil {
|
||||
// Non-fatal, log and continue
|
||||
fmt.Printf("Warning: failed to backup Caddyfile: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckMountedImport checks for mounted Caddyfile on startup.
|
||||
func CheckMountedImport(db *gorm.DB, mountPath, caddyBinary, importDir string) error {
|
||||
if _, err := os.Stat(mountPath); os.IsNotExist(err) {
|
||||
return nil // No mounted file, skip
|
||||
}
|
||||
|
||||
// Check if already processed
|
||||
var count int64
|
||||
db.Model(&models.ImportSession{}).Where("source_file = ? AND status IN ?",
|
||||
mountPath, []string{"pending", "reviewing", "committed"}).Count(&count)
|
||||
|
||||
if count > 0 {
|
||||
return nil // Already processed
|
||||
}
|
||||
|
||||
handler := NewImportHandler(db, caddyBinary, importDir)
|
||||
return handler.processImport(mountPath, mountPath)
|
||||
}
|
||||
|
||||
func mustMarshal(v interface{}) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
// RemoteServerHandler handles HTTP requests for remote server management.
|
||||
type RemoteServerHandler struct {
|
||||
service *services.RemoteServerService
|
||||
}
|
||||
|
||||
// NewRemoteServerHandler creates a new remote server handler.
|
||||
func NewRemoteServerHandler(db *gorm.DB) *RemoteServerHandler {
|
||||
return &RemoteServerHandler{
|
||||
service: services.NewRemoteServerService(db),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers remote server routes.
|
||||
func (h *RemoteServerHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/remote-servers", h.List)
|
||||
router.POST("/remote-servers", h.Create)
|
||||
router.GET("/remote-servers/:uuid", h.Get)
|
||||
router.PUT("/remote-servers/:uuid", h.Update)
|
||||
router.DELETE("/remote-servers/:uuid", h.Delete)
|
||||
}
|
||||
|
||||
// List retrieves all remote servers.
|
||||
func (h *RemoteServerHandler) List(c *gin.Context) {
|
||||
enabledOnly := c.Query("enabled") == "true"
|
||||
|
||||
servers, err := h.service.List(enabledOnly)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, servers)
|
||||
}
|
||||
|
||||
// Create creates a new remote server.
|
||||
func (h *RemoteServerHandler) Create(c *gin.Context) {
|
||||
var server models.RemoteServer
|
||||
if err := c.ShouldBindJSON(&server); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
server.UUID = uuid.NewString()
|
||||
|
||||
if err := h.service.Create(&server); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, server)
|
||||
}
|
||||
|
||||
// Get retrieves a remote server by UUID.
|
||||
func (h *RemoteServerHandler) Get(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
server, err := h.service.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, server)
|
||||
}
|
||||
|
||||
// Update updates an existing remote server.
|
||||
func (h *RemoteServerHandler) Update(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
server, err := h.service.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(server); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Update(server); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, server)
|
||||
}
|
||||
|
||||
// Delete removes a remote server.
|
||||
func (h *RemoteServerHandler) Delete(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
server, err := h.service.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(server.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// Register wires up API routes and performs automatic migrations.
|
||||
func Register(router *gin.Engine, db *gorm.DB) error {
|
||||
// AutoMigrate all models for Issue #5 persistence layer
|
||||
if err := db.AutoMigrate(
|
||||
&models.ProxyHost{},
|
||||
&models.CaddyConfig{},
|
||||
&models.RemoteServer{},
|
||||
&models.SSLCertificate{},
|
||||
&models.AccessList{},
|
||||
&models.User{},
|
||||
&models.Setting{},
|
||||
&models.ImportSession{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
|
||||
router.GET("/api/v1/health", handlers.HealthHandler)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
|
||||
proxyHostHandler := handlers.NewProxyHostHandler(db)
|
||||
proxyHostHandler.RegisterRoutes(api)
|
||||
|
||||
remoteServerHandler := handlers.NewRemoteServerHandler(db)
|
||||
remoteServerHandler.RegisterRoutes(api)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterImportHandler wires up import routes with config dependencies.
|
||||
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importDir string) {
|
||||
importHandler := handlers.NewImportHandler(db, caddyBinary, importDir)
|
||||
api := router.Group("/api/v1")
|
||||
importHandler.RegisterRoutes(api)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// ParsedHost represents a single host detected during Caddyfile import.
|
||||
type ParsedHost struct {
|
||||
Domain string `json:"domain"`
|
||||
TargetScheme string `json:"target_scheme"`
|
||||
TargetHost string `json:"target_host"`
|
||||
TargetPort int `json:"target_port"`
|
||||
EnableTLS bool `json:"enable_tls"`
|
||||
EnableWS bool `json:"enable_websockets"`
|
||||
RawJSON string `json:"raw_json"` // Original Caddy JSON for this route
|
||||
Warnings []string `json:"warnings"` // Unsupported features
|
||||
}
|
||||
|
||||
// ImportResult contains parsed hosts and detected conflicts.
|
||||
type ImportResult struct {
|
||||
Hosts []ParsedHost `json:"hosts"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// Importer handles Caddyfile parsing and conversion to CPM+ models.
|
||||
type Importer struct {
|
||||
caddyBinaryPath string
|
||||
}
|
||||
|
||||
// NewImporter creates a new Caddyfile importer.
|
||||
func NewImporter(binaryPath string) *Importer {
|
||||
if binaryPath == "" {
|
||||
binaryPath = "caddy" // Default to PATH
|
||||
}
|
||||
return &Importer{caddyBinaryPath: binaryPath}
|
||||
}
|
||||
|
||||
// ParseCaddyfile reads a Caddyfile and converts it to Caddy JSON.
|
||||
func (i *Importer) ParseCaddyfile(caddyfilePath string) ([]byte, error) {
|
||||
if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("caddyfile not found: %s", caddyfilePath)
|
||||
}
|
||||
|
||||
cmd := exec.Command(i.caddyBinaryPath, "adapt", "--config", caddyfilePath, "--adapter", "caddyfile")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("caddy adapt failed: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// ExtractHosts parses Caddy JSON and extracts proxy host information.
|
||||
func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) {
|
||||
var config CaddyConfig
|
||||
if err := json.Unmarshal(caddyJSON, &config); err != nil {
|
||||
return nil, fmt.Errorf("parsing caddy json: %w", err)
|
||||
}
|
||||
|
||||
result := &ImportResult{
|
||||
Hosts: []ParsedHost{},
|
||||
Conflicts: []string{},
|
||||
Errors: []string{},
|
||||
}
|
||||
|
||||
if config.Apps == nil || config.Apps.HTTP == nil || config.Apps.HTTP.Servers == nil {
|
||||
return result, nil // Empty config
|
||||
}
|
||||
|
||||
seenDomains := make(map[string]bool)
|
||||
|
||||
for serverName, server := range config.Apps.HTTP.Servers {
|
||||
for routeIdx, route := range server.Routes {
|
||||
for _, match := range route.Match {
|
||||
for _, hostMatcher := range match.Host {
|
||||
domain := hostMatcher
|
||||
|
||||
// Check for duplicate domains
|
||||
if seenDomains[domain] {
|
||||
result.Conflicts = append(result.Conflicts,
|
||||
fmt.Sprintf("Duplicate domain detected: %s", domain))
|
||||
continue
|
||||
}
|
||||
seenDomains[domain] = true
|
||||
|
||||
// Extract reverse proxy handler
|
||||
host := ParsedHost{
|
||||
Domain: domain,
|
||||
EnableTLS: strings.HasPrefix(domain, "https") || server.TLSConnectionPolicies != nil,
|
||||
}
|
||||
|
||||
// Find reverse_proxy handler
|
||||
for _, handler := range route.Handle {
|
||||
if handler.Handler == "reverse_proxy" {
|
||||
upstreams, _ := handler.Upstreams.([]interface{})
|
||||
if len(upstreams) > 0 {
|
||||
if upstream, ok := upstreams[0].(map[string]interface{}); ok {
|
||||
dial, _ := upstream["dial"].(string)
|
||||
if dial != "" {
|
||||
parts := strings.Split(dial, ":")
|
||||
if len(parts) == 2 {
|
||||
host.TargetHost = parts[0]
|
||||
fmt.Sscanf(parts[1], "%d", &host.TargetPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for websocket support
|
||||
if headers, ok := handler.Headers.(map[string]interface{}); ok {
|
||||
if upgrade, ok := headers["Upgrade"].([]interface{}); ok {
|
||||
for _, v := range upgrade {
|
||||
if v == "websocket" {
|
||||
host.EnableWS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default scheme
|
||||
host.TargetScheme = "http"
|
||||
if host.EnableTLS {
|
||||
host.TargetScheme = "https"
|
||||
}
|
||||
}
|
||||
|
||||
// Detect unsupported features
|
||||
if handler.Handler == "rewrite" {
|
||||
host.Warnings = append(host.Warnings, "Rewrite rules not supported - manual configuration required")
|
||||
}
|
||||
if handler.Handler == "file_server" {
|
||||
host.Warnings = append(host.Warnings, "File server directives not supported")
|
||||
}
|
||||
}
|
||||
|
||||
// Store raw JSON for this route
|
||||
routeJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"server": serverName,
|
||||
"route": routeIdx,
|
||||
"data": route,
|
||||
})
|
||||
host.RawJSON = string(routeJSON)
|
||||
|
||||
result.Hosts = append(result.Hosts, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ImportFile performs complete import: parse Caddyfile and extract hosts.
|
||||
func (i *Importer) ImportFile(caddyfilePath string) (*ImportResult, error) {
|
||||
caddyJSON, err := i.ParseCaddyfile(caddyfilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return i.ExtractHosts(caddyJSON)
|
||||
}
|
||||
|
||||
// ConvertToProxyHosts converts parsed hosts to ProxyHost models.
|
||||
func ConvertToProxyHosts(parsedHosts []ParsedHost) []models.ProxyHost {
|
||||
hosts := make([]models.ProxyHost, 0, len(parsedHosts))
|
||||
|
||||
for _, parsed := range parsedHosts {
|
||||
if parsed.TargetHost == "" || parsed.TargetPort == 0 {
|
||||
continue // Skip invalid entries
|
||||
}
|
||||
|
||||
hosts = append(hosts, models.ProxyHost{
|
||||
Name: parsed.Domain, // Can be customized by user during review
|
||||
Domain: parsed.Domain,
|
||||
TargetScheme: parsed.TargetScheme,
|
||||
TargetHost: parsed.TargetHost,
|
||||
TargetPort: parsed.TargetPort,
|
||||
EnableTLS: parsed.EnableTLS,
|
||||
EnableWS: parsed.EnableWS,
|
||||
})
|
||||
}
|
||||
|
||||
return hosts
|
||||
}
|
||||
|
||||
// ValidateCaddyBinary checks if the Caddy binary is available.
|
||||
func (i *Importer) ValidateCaddyBinary() error {
|
||||
cmd := exec.Command(i.caddyBinaryPath, "version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errors.New("caddy binary not found or not executable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackupCaddyfile creates a timestamped backup of the original Caddyfile.
|
||||
func BackupCaddyfile(originalPath, backupDir string) (string, error) {
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("creating backup directory: %w", err)
|
||||
}
|
||||
|
||||
timestamp := fmt.Sprintf("%d", os.Getpid()) // Simple timestamp placeholder
|
||||
backupPath := filepath.Join(backupDir, fmt.Sprintf("Caddyfile.%s.backup", timestamp))
|
||||
|
||||
input, err := os.ReadFile(originalPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading original file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(backupPath, input, 0644); err != nil {
|
||||
return "", fmt.Errorf("writing backup: %w", err)
|
||||
}
|
||||
|
||||
return backupPath, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config captures runtime configuration sourced from environment variables.
|
||||
type Config struct {
|
||||
Environment string
|
||||
HTTPPort string
|
||||
DatabasePath string
|
||||
FrontendDir string
|
||||
CaddyAdminAPI string
|
||||
CaddyConfigDir string
|
||||
CaddyBinary string
|
||||
ImportCaddyfile string
|
||||
ImportDir string
|
||||
}
|
||||
|
||||
// Load reads env vars and falls back to defaults so the server can boot with zero configuration.
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Environment: getEnv("CPM_ENV", "development"),
|
||||
HTTPPort: getEnv("CPM_HTTP_PORT", "8080"),
|
||||
DatabasePath: getEnv("CPM_DB_PATH", filepath.Join("data", "cpm.db")),
|
||||
FrontendDir: getEnv("CPM_FRONTEND_DIR", filepath.Clean(filepath.Join("..", "frontend", "dist"))),
|
||||
CaddyAdminAPI: getEnv("CPM_CADDY_ADMIN_API", "http://localhost:2019"),
|
||||
CaddyConfigDir: getEnv("CPM_CADDY_CONFIG_DIR", filepath.Join("data", "caddy")),
|
||||
CaddyBinary: getEnv("CPM_CADDY_BINARY", "caddy"),
|
||||
ImportCaddyfile: getEnv("CPM_IMPORT_CADDYFILE", "/import/Caddyfile"),
|
||||
ImportDir: getEnv("CPM_IMPORT_DIR", filepath.Join("data", "imports")),
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o755); err != nil {
|
||||
return Config{}, fmt.Errorf("ensure data directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.CaddyConfigDir, 0o755); err != nil {
|
||||
return Config{}, fmt.Errorf("ensure caddy config directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.ImportDir, 0o755); err != nil {
|
||||
return Config{}, fmt.Errorf("ensure import directory: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccessList defines IP-based or auth-based access control rules
|
||||
// that can be applied to proxy hosts.
|
||||
type AccessList struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"` // "allow", "deny", "basic_auth", "forward_auth"
|
||||
Rules string `json:"rules" gorm:"type:text"` // JSON array of rule definitions
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ImportSession tracks Caddyfile import operations with pending state
|
||||
// until user reviews and confirms via UI.
|
||||
type ImportSession struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
SourceFile string `json:"source_file"` // Path to original Caddyfile
|
||||
Status string `json:"status" gorm:"default:'pending'"` // "pending", "reviewing", "committed", "rejected", "failed"
|
||||
ParsedData string `json:"parsed_data" gorm:"type:text"` // JSON representation of detected hosts
|
||||
ConflictReport string `json:"conflict_report" gorm:"type:text"` // JSON array of conflicts
|
||||
UserResolutions string `json:"user_resolutions" gorm:"type:text"` // JSON map of conflict resolutions
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CommittedAt *time.Time `json:"committed_at,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RemoteServer represents a known backend server that can be selected
|
||||
// when creating proxy hosts, eliminating manual IP/port entry.
|
||||
type RemoteServer struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Provider string `json:"provider"` // e.g., "docker", "vm", "cloud", "manual"
|
||||
Host string `json:"host"` // IP address or hostname
|
||||
Port int `json:"port"`
|
||||
Scheme string `json:"scheme"` // http/https
|
||||
Tags string `json:"tags"` // comma-separated tags for filtering
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
LastChecked *time.Time `json:"last_checked,omitempty"`
|
||||
Reachable bool `json:"reachable" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Setting stores global application configuration as key-value pairs.
|
||||
// Used for system-wide preferences, feature flags, and runtime config.
|
||||
type Setting struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Key string `json:"key" gorm:"uniqueIndex"`
|
||||
Value string `json:"value" gorm:"type:text"`
|
||||
Type string `json:"type"` // "string", "int", "bool", "json"
|
||||
Category string `json:"category"` // "general", "security", "caddy", "smtp", etc.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SSLCertificate represents TLS certificates managed by CPM+.
|
||||
// Can be Let's Encrypt auto-generated or custom uploaded certs.
|
||||
type SSLCertificate struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"` // "letsencrypt", "custom", "self-signed"
|
||||
Domains string `json:"domains"` // comma-separated list of domains
|
||||
Certificate string `json:"certificate" gorm:"type:text"` // PEM-encoded certificate
|
||||
PrivateKey string `json:"private_key" gorm:"type:text"` // PEM-encoded private key
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
AutoRenew bool `json:"auto_renew" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents authenticated users with role-based access control.
|
||||
// Supports local auth, SSO integration planned for later phases.
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Email string `json:"email" gorm:"uniqueIndex"`
|
||||
PasswordHash string `json:"-"` // Never serialize password hash
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer"
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// ProxyHostService encapsulates business logic for proxy host management.
|
||||
type ProxyHostService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewProxyHostService creates a new proxy host service.
|
||||
func NewProxyHostService(db *gorm.DB) *ProxyHostService {
|
||||
return &ProxyHostService{db: db}
|
||||
}
|
||||
|
||||
// ValidateUniqueDomain ensures no duplicate domains exist before creation/update.
|
||||
func (s *ProxyHostService) ValidateUniqueDomain(domain string, excludeID uint) error {
|
||||
var count int64
|
||||
query := s.db.Model(&models.ProxyHost{}).Where("domain = ?", domain)
|
||||
|
||||
if excludeID > 0 {
|
||||
query = query.Where("id != ?", excludeID)
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("checking domain uniqueness: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return errors.New("domain already exists")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create validates and creates a new proxy host.
|
||||
func (s *ProxyHostService) Create(host *models.ProxyHost) error {
|
||||
if err := s.ValidateUniqueDomain(host.Domain, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Create(host).Error
|
||||
}
|
||||
|
||||
// Update validates and updates an existing proxy host.
|
||||
func (s *ProxyHostService) Update(host *models.ProxyHost) error {
|
||||
if err := s.ValidateUniqueDomain(host.Domain, host.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Save(host).Error
|
||||
}
|
||||
|
||||
// Delete removes a proxy host.
|
||||
func (s *ProxyHostService) Delete(id uint) error {
|
||||
return s.db.Delete(&models.ProxyHost{}, id).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves a proxy host by ID.
|
||||
func (s *ProxyHostService) GetByID(id uint) (*models.ProxyHost, error) {
|
||||
var host models.ProxyHost
|
||||
if err := s.db.First(&host, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &host, nil
|
||||
}
|
||||
|
||||
// GetByUUID retrieves a proxy host by UUID.
|
||||
func (s *ProxyHostService) GetByUUID(uuid string) (*models.ProxyHost, error) {
|
||||
var host models.ProxyHost
|
||||
if err := s.db.Where("uuid = ?", uuid).First(&host).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &host, nil
|
||||
}
|
||||
|
||||
// List retrieves all proxy hosts.
|
||||
func (s *ProxyHostService) List() ([]models.ProxyHost, error) {
|
||||
var hosts []models.ProxyHost
|
||||
if err := s.db.Find(&hosts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// RemoteServerService encapsulates business logic for remote server management.
|
||||
type RemoteServerService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewRemoteServerService creates a new remote server service.
|
||||
func NewRemoteServerService(db *gorm.DB) *RemoteServerService {
|
||||
return &RemoteServerService{db: db}
|
||||
}
|
||||
|
||||
// ValidateUniqueServer ensures no duplicate name+host+port combinations.
|
||||
func (s *RemoteServerService) ValidateUniqueServer(name, host string, port int, excludeID uint) error {
|
||||
var count int64
|
||||
query := s.db.Model(&models.RemoteServer{}).Where("name = ? OR (host = ? AND port = ?)", name, host, port)
|
||||
|
||||
if excludeID > 0 {
|
||||
query = query.Where("id != ?", excludeID)
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("checking server uniqueness: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return errors.New("server with same name or host:port already exists")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create validates and creates a new remote server.
|
||||
func (s *RemoteServerService) Create(server *models.RemoteServer) error {
|
||||
if err := s.ValidateUniqueServer(server.Name, server.Host, server.Port, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Create(server).Error
|
||||
}
|
||||
|
||||
// Update validates and updates an existing remote server.
|
||||
func (s *RemoteServerService) Update(server *models.RemoteServer) error {
|
||||
if err := s.ValidateUniqueServer(server.Name, server.Host, server.Port, server.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Save(server).Error
|
||||
}
|
||||
|
||||
// Delete removes a remote server.
|
||||
func (s *RemoteServerService) Delete(id uint) error {
|
||||
return s.db.Delete(&models.RemoteServer{}, id).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves a remote server by ID.
|
||||
func (s *RemoteServerService) GetByID(id uint) (*models.RemoteServer, error) {
|
||||
var server models.RemoteServer
|
||||
if err := s.db.First(&server, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
// GetByUUID retrieves a remote server by UUID.
|
||||
func (s *RemoteServerService) GetByUUID(uuid string) (*models.RemoteServer, error) {
|
||||
var server models.RemoteServer
|
||||
if err := s.db.Where("uuid = ?", uuid).First(&server).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
// List retrieves all remote servers, optionally filtering by enabled status.
|
||||
func (s *RemoteServerService) List(enabledOnly bool) ([]models.RemoteServer, error) {
|
||||
var servers []models.RemoteServer
|
||||
query := s.db
|
||||
|
||||
if enabledOnly {
|
||||
query = query.Where("enabled = ?", true)
|
||||
}
|
||||
|
||||
if err := query.Order("name ASC").Find(&servers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
Reference in New Issue
Block a user