chore: defer Sourcery auth; continue work

This commit is contained in:
Wikid82
2025-11-17 22:08:59 -05:00
parent 4f3b7d8f99
commit 4602cbd100
6114 changed files with 1457188 additions and 0 deletions
+5
View File
@@ -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
+48
View File
@@ -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.
+3
View File
@@ -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)
}
+47
View File
@@ -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)
}
+224
View File
@@ -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
}
+57
View File
@@ -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
}
+19
View File
@@ -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"`
}
+21
View File
@@ -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"`
}
+24
View File
@@ -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"`
}
+16
View File
@@ -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"`
}
+20
View File
@@ -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
}