diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go new file mode 100644 index 00000000..5bb42718 --- /dev/null +++ b/backend/internal/api/handlers/auth_handler.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "net/http" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + authService *services.AuthService +} + +func NewAuthHandler(authService *services.AuthService) *AuthHandler { + return &AuthHandler{authService: authService} +} + +type LoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + token, err := h.authService.Login(req.Email, req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + // Set cookie + c.SetCookie("auth_token", token, 3600*24, "/", "", false, true) // Secure should be true in prod + + c.JSON(http.StatusOK, gin.H{"token": token}) +} + +type RegisterRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + Name string `json:"name" binding:"required"` +} + +func (h *AuthHandler) Register(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.authService.Register(req.Email, req.Password, req.Name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, user) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + c.SetCookie("auth_token", "", -1, "/", "", false, true) + c.JSON(http.StatusOK, gin.H{"message": "Logged out"}) +} + +func (h *AuthHandler) Me(c *gin.Context) { + userID, _ := c.Get("userID") + role, _ := c.Get("role") + c.JSON(http.StatusOK, gin.H{"user_id": userID, "role": role}) +} + +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` +} + +func (h *AuthHandler) ChangePassword(c *gin.Context) { + var req ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + if err := h.authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"}) +} diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go new file mode 100644 index 00000000..d46ad2d4 --- /dev/null +++ b/backend/internal/api/handlers/certificate_handler.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" +) + +type CertificateHandler struct { + service *services.CertificateService +} + +func NewCertificateHandler(service *services.CertificateService) *CertificateHandler { + return &CertificateHandler{service: service} +} + +func (h *CertificateHandler) List(c *gin.Context) { + certs, err := h.service.ListCertificates() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, certs) +} diff --git a/backend/internal/api/handlers/handlers_test.go b/backend/internal/api/handlers/handlers_test.go new file mode 100644 index 00000000..5b7db0c5 --- /dev/null +++ b/backend/internal/api/handlers/handlers_test.go @@ -0,0 +1,329 @@ +package handlers_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +func setupTestDB() *gorm.DB { + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + if err != nil { + panic("failed to connect to test database") + } + + // Auto migrate + db.AutoMigrate( + &models.ProxyHost{}, + &models.Location{}, + &models.RemoteServer{}, + &models.ImportSession{}, + ) + + return db +} + +func TestRemoteServerHandler_List(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB() + + // Create test server + server := &models.RemoteServer{ + UUID: uuid.NewString(), + Name: "Test Server", + Provider: "docker", + Host: "localhost", + Port: 8080, + Enabled: true, + } + db.Create(server) + + handler := handlers.NewRemoteServerHandler(db) + router := gin.New() + handler.RegisterRoutes(router.Group("/api/v1")) + + // Test List + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/remote-servers", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var servers []models.RemoteServer + err := json.Unmarshal(w.Body.Bytes(), &servers) + assert.NoError(t, err) + assert.Len(t, servers, 1) + assert.Equal(t, "Test Server", servers[0].Name) +} + +func TestRemoteServerHandler_Create(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB() + + handler := handlers.NewRemoteServerHandler(db) + router := gin.New() + handler.RegisterRoutes(router.Group("/api/v1")) + + // Test Create + serverData := map[string]interface{}{ + "name": "New Server", + "provider": "generic", + "host": "192.168.1.100", + "port": 3000, + "enabled": true, + } + body, _ := json.Marshal(serverData) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var server models.RemoteServer + err := json.Unmarshal(w.Body.Bytes(), &server) + assert.NoError(t, err) + assert.Equal(t, "New Server", server.Name) + assert.NotEmpty(t, server.UUID) +} + +func TestRemoteServerHandler_TestConnection(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB() + + // Create test server + server := &models.RemoteServer{ + UUID: uuid.NewString(), + Name: "Test Server", + Provider: "docker", + Host: "localhost", + Port: 99999, // Invalid port to test failure + Enabled: true, + } + db.Create(server) + + handler := handlers.NewRemoteServerHandler(db) + router := gin.New() + handler.RegisterRoutes(router.Group("/api/v1")) + + // Test connection + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/remote-servers/"+server.UUID+"/test", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var result map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &result) + assert.NoError(t, err) + assert.False(t, result["reachable"].(bool)) + assert.NotEmpty(t, result["error"]) +} + +func TestRemoteServerHandler_Get(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB() + + // Create test server + server := &models.RemoteServer{ + UUID: uuid.NewString(), + Name: "Test Server", + Provider: "docker", + Host: "localhost", + Port: 8080, + Enabled: true, + } + db.Create(server) + + handler := handlers.NewRemoteServerHandler(db) + router := gin.New() + handler.RegisterRoutes(router.Group("/api/v1")) + + // Test Get + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var fetched models.RemoteServer + err := json.Unmarshal(w.Body.Bytes(), &fetched) + assert.NoError(t, err) + assert.Equal(t, server.UUID, fetched.UUID) +} + +func TestRemoteServerHandler_Update(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB() + + // Create test server + server := &models.RemoteServer{ + UUID: uuid.NewString(), + Name: "Test Server", + Provider: "docker", + Host: "localhost", + Port: 8080, + Enabled: true, + } + db.Create(server) + + handler := handlers.NewRemoteServerHandler(db) + router := gin.New() + handler.RegisterRoutes(router.Group("/api/v1")) + + // Test Update + updateData := map[string]interface{}{ + "name": "Updated Server", + "provider": "generic", + "host": "10.0.0.1", + "port": 9000, + "enabled": false, + } + body, _ := json.Marshal(updateData) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", "/api/v1/remote-servers/"+server.UUID, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var updated models.RemoteServer + err := json.Unmarshal(w.Body.Bytes(), &updated) + assert.NoError(t, err) + assert.Equal(t, "Updated Server", updated.Name) + assert.Equal(t, "generic", updated.Provider) + assert.False(t, updated.Enabled) +} + +func TestRemoteServerHandler_Delete(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB() + + // Create test server + server := &models.RemoteServer{ + UUID: uuid.NewString(), + Name: "Test Server", + Provider: "docker", + Host: "localhost", + Port: 8080, + Enabled: true, + } + db.Create(server) + + handler := handlers.NewRemoteServerHandler(db) + router := gin.New() + handler.RegisterRoutes(router.Group("/api/v1")) + + // Test Delete + w := httptest.NewRecorder() + req, _ := http.NewRequest("DELETE", "/api/v1/remote-servers/"+server.UUID, nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + + // Verify Delete + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, nil) + router.ServeHTTP(w2, req2) + + assert.Equal(t, http.StatusNotFound, w2.Code) +} + +func TestProxyHostHandler_List(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB() + + // Create test proxy host + host := &models.ProxyHost{ + UUID: uuid.NewString(), + Name: "Test Host", + DomainNames: "test.local", + ForwardScheme: "http", + ForwardHost: "localhost", + ForwardPort: 3000, + Enabled: true, + } + db.Create(host) + + handler := handlers.NewProxyHostHandler(db) + router := gin.New() + handler.RegisterRoutes(router.Group("/api/v1")) + + // Test List + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/proxy-hosts", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var hosts []models.ProxyHost + err := json.Unmarshal(w.Body.Bytes(), &hosts) + assert.NoError(t, err) + assert.Len(t, hosts, 1) + assert.Equal(t, "Test Host", hosts[0].Name) +} + +func TestProxyHostHandler_Create(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB() + + handler := handlers.NewProxyHostHandler(db) + router := gin.New() + handler.RegisterRoutes(router.Group("/api/v1")) + + // Test Create + hostData := map[string]interface{}{ + "name": "New Host", + "domain_names": "new.local", + "forward_scheme": "http", + "forward_host": "192.168.1.200", + "forward_port": 8080, + "enabled": true, + } + body, _ := json.Marshal(hostData) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/proxy-hosts", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var host models.ProxyHost + err := json.Unmarshal(w.Body.Bytes(), &host) + assert.NoError(t, err) + assert.Equal(t, "New Host", host.Name) + assert.Equal(t, "new.local", host.DomainNames) + assert.NotEmpty(t, host.UUID) +} + +func TestHealthHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.GET("/health", handlers.HealthHandler) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/health", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var result map[string]string + err := json.Unmarshal(w.Body.Bytes(), &result) + assert.NoError(t, err) + assert.Equal(t, "ok", result["status"]) +} diff --git a/backend/internal/api/handlers/health_handler.go b/backend/internal/api/handlers/health_handler.go new file mode 100644 index 00000000..2da47944 --- /dev/null +++ b/backend/internal/api/handlers/health_handler.go @@ -0,0 +1,19 @@ +package handlers + +import ( + "net/http" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version" + "github.com/gin-gonic/gin" +) + +// HealthHandler responds with basic service metadata for uptime checks. +func HealthHandler(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "service": version.Name, + "version": version.Version, + "git_commit": version.GitCommit, + "build_time": version.BuildTime, + }) +} diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go new file mode 100644 index 00000000..b0a0da35 --- /dev/null +++ b/backend/internal/api/handlers/import_handler.go @@ -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.DomainNames] + + if action == "skip" { + skipped++ + continue + } + + if action == "rename" { + host.DomainNames = host.DomainNames + "-imported" + } + + host.UUID = uuid.NewString() + + if err := h.proxyHostSvc.Create(&host); err != nil { + errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, 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.DomainNames] = true + } + + for _, parsed := range result.Hosts { + if existingDomains[parsed.DomainNames] { + result.Conflicts = append(result.Conflicts, + fmt.Sprintf("Domain '%s' already exists in CPM+", parsed.DomainNames)) + } + } + + // 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 +} diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go new file mode 100644 index 00000000..eb8bbf38 --- /dev/null +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -0,0 +1,121 @@ +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" +) + +// ProxyHostHandler handles CRUD operations for proxy hosts. +type ProxyHostHandler struct { + service *services.ProxyHostService +} + +// NewProxyHostHandler creates a new proxy host handler. +func NewProxyHostHandler(db *gorm.DB) *ProxyHostHandler { + return &ProxyHostHandler{ + service: services.NewProxyHostService(db), + } +} + +// RegisterRoutes registers proxy host routes. +func (h *ProxyHostHandler) RegisterRoutes(router *gin.RouterGroup) { + router.GET("/proxy-hosts", h.List) + router.POST("/proxy-hosts", h.Create) + router.GET("/proxy-hosts/:uuid", h.Get) + router.PUT("/proxy-hosts/:uuid", h.Update) + router.DELETE("/proxy-hosts/:uuid", h.Delete) +} + +// List retrieves all proxy hosts. +func (h *ProxyHostHandler) List(c *gin.Context) { + hosts, err := h.service.List() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, hosts) +} + +// Create creates a new proxy host. +func (h *ProxyHostHandler) Create(c *gin.Context) { + var host models.ProxyHost + if err := c.ShouldBindJSON(&host); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + host.UUID = uuid.NewString() + + // Assign UUIDs to locations + for i := range host.Locations { + host.Locations[i].UUID = uuid.NewString() + } + + if err := h.service.Create(&host); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, host) +} + +// Get retrieves a proxy host by UUID. +func (h *ProxyHostHandler) Get(c *gin.Context) { + uuid := c.Param("uuid") + + host, err := h.service.GetByUUID(uuid) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"}) + return + } + + c.JSON(http.StatusOK, host) +} + +// Update updates an existing proxy host. +func (h *ProxyHostHandler) Update(c *gin.Context) { + uuid := c.Param("uuid") + + host, err := h.service.GetByUUID(uuid) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"}) + return + } + + if err := c.ShouldBindJSON(host); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.service.Update(host); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, host) +} + +// Delete removes a proxy host. +func (h *ProxyHostHandler) Delete(c *gin.Context) { + uuid := c.Param("uuid") + + host, err := h.service.GetByUUID(uuid) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"}) + return + } + + if err := h.service.Delete(host.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "proxy host deleted"}) +} diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go new file mode 100644 index 00000000..e9ca44ba --- /dev/null +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{})) + + h := NewProxyHostHandler(db) + r := gin.New() + api := r.Group("/api/v1") + h.RegisterRoutes(api) + + return r, db +} + +func TestProxyHostLifecycle(t *testing.T) { + router, _ := setupTestRouter(t) + + body := `{"name":"Media","domain_names":"media.example.com","forward_scheme":"http","forward_host":"media","forward_port":32400,"enabled":true}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + require.Equal(t, http.StatusCreated, resp.Code) + + var created models.ProxyHost + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created)) + require.Equal(t, "media.example.com", created.DomainNames) + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil) + listResp := httptest.NewRecorder() + router.ServeHTTP(listResp, listReq) + require.Equal(t, http.StatusOK, listResp.Code) + + var hosts []models.ProxyHost + require.NoError(t, json.Unmarshal(listResp.Body.Bytes(), &hosts)) + require.Len(t, hosts, 1) + + // Get by ID + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, nil) + getResp := httptest.NewRecorder() + router.ServeHTTP(getResp, getReq) + require.Equal(t, http.StatusOK, getResp.Code) + + var fetched models.ProxyHost + require.NoError(t, json.Unmarshal(getResp.Body.Bytes(), &fetched)) + require.Equal(t, created.UUID, fetched.UUID) + + // Update + updateBody := `{"name":"Media Updated","domain_names":"media.example.com","forward_scheme":"http","forward_host":"media","forward_port":32400,"enabled":false}` + updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+created.UUID, strings.NewReader(updateBody)) + updateReq.Header.Set("Content-Type", "application/json") + updateResp := httptest.NewRecorder() + router.ServeHTTP(updateResp, updateReq) + require.Equal(t, http.StatusOK, updateResp.Code) + + var updated models.ProxyHost + require.NoError(t, json.Unmarshal(updateResp.Body.Bytes(), &updated)) + require.Equal(t, "Media Updated", updated.Name) + require.False(t, updated.Enabled) + + // Delete + delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+created.UUID, nil) + delResp := httptest.NewRecorder() + router.ServeHTTP(delResp, delReq) + require.Equal(t, http.StatusOK, delResp.Code) + + // Verify Delete + getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, nil) + getResp2 := httptest.NewRecorder() + router.ServeHTTP(getResp2, getReq2) + require.Equal(t, http.StatusNotFound, getResp2.Code) +} + +func TestProxyHostErrors(t *testing.T) { + router, _ := setupTestRouter(t) + + // Get non-existent + req := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/non-existent-uuid", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + require.Equal(t, http.StatusNotFound, resp.Code) + + // Update non-existent + updateBody := `{"name":"Media Updated"}` + updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/non-existent-uuid", strings.NewReader(updateBody)) + updateReq.Header.Set("Content-Type", "application/json") + updateResp := httptest.NewRecorder() + router.ServeHTTP(updateResp, updateReq) + require.Equal(t, http.StatusNotFound, updateResp.Code) + + // Delete non-existent + delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/non-existent-uuid", nil) + delResp := httptest.NewRecorder() + router.ServeHTTP(delResp, delReq) + require.Equal(t, http.StatusNotFound, delResp.Code) +} diff --git a/backend/internal/api/handlers/remote_server_handler.go b/backend/internal/api/handlers/remote_server_handler.go new file mode 100644 index 00000000..274eebc4 --- /dev/null +++ b/backend/internal/api/handlers/remote_server_handler.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "fmt" + "net" + "net/http" + "time" + + "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) + router.POST("/remote-servers/:uuid/test", h.TestConnection) +} + +// 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) +} + +// TestConnection tests the TCP connection to a remote server. +func (h *RemoteServerHandler) TestConnection(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 + } + + // Test TCP connection with 5 second timeout + address := net.JoinHostPort(server.Host, fmt.Sprintf("%d", server.Port)) + conn, err := net.DialTimeout("tcp", address, 5*time.Second) + + result := gin.H{ + "server_uuid": server.UUID, + "address": address, + "timestamp": time.Now().UTC(), + } + + if err != nil { + result["reachable"] = false + result["error"] = err.Error() + + // Update server reachability status + server.Reachable = false + now := time.Now().UTC() + server.LastChecked = &now + h.service.Update(server) + + c.JSON(http.StatusOK, result) + return + } + defer conn.Close() + + // Connection successful + result["reachable"] = true + result["latency_ms"] = time.Since(time.Now()).Milliseconds() + + // Update server reachability status + server.Reachable = true + now := time.Now().UTC() + server.LastChecked = &now + h.service.Update(server) + + c.JSON(http.StatusOK, result) +} diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go new file mode 100644 index 00000000..c7a7473b --- /dev/null +++ b/backend/internal/api/handlers/user_handler.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" +) + +type UserHandler struct { + DB *gorm.DB +} + +func NewUserHandler(db *gorm.DB) *UserHandler { + return &UserHandler{DB: db} +} + +func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) { + r.GET("/setup", h.GetSetupStatus) + r.POST("/setup", h.Setup) +} + +// GetSetupStatus checks if the application needs initial setup (i.e., no users exist). +func (h *UserHandler) GetSetupStatus(c *gin.Context) { + var count int64 + if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "setupRequired": count == 0, + }) +} + +type SetupRequest struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` +} + +// Setup creates the initial admin user and configures the ACME email. +func (h *UserHandler) Setup(c *gin.Context) { + // 1. Check if setup is allowed + var count int64 + if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"}) + return + } + + if count > 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"}) + return + } + + // 2. Parse request + var req SetupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 3. Create User + user := models.User{ + UUID: uuid.New().String(), + Name: req.Name, + Email: req.Email, + Role: "admin", + Enabled: true, + } + + if err := user.SetPassword(req.Password); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + // 4. Create Setting for ACME Email + acmeEmailSetting := models.Setting{ + Key: "caddy.acme_email", + Value: req.Email, + Type: "string", + Category: "caddy", + } + + // Transaction to ensure both succeed + err := h.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&user).Error; err != nil { + return err + } + // Use Save to update if exists (though it shouldn't in fresh setup) or create + if err := tx.Where(models.Setting{Key: "caddy.acme_email"}).Assign(models.Setting{Value: req.Email}).FirstOrCreate(&acmeEmailSetting).Error; err != nil { + return err + } + return nil + }) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Setup completed successfully", + "user": gin.H{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + }, + }) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go new file mode 100644 index 00000000..877f4132 --- /dev/null +++ b/backend/internal/api/middleware/auth.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" + "github.com/gin-gonic/gin" +) + +func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + // Try cookie + cookie, err := c.Cookie("auth_token") + if err == nil { + authHeader = "Bearer " + cookie + } + } + + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + claims, err := authService.ValidateToken(tokenString) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + return + } + + c.Set("userID", claims.UserID) + c.Set("role", claims.Role) + c.Next() + } +} + +func RequireRole(role string) gin.HandlerFunc { + return func(c *gin.Context) { + userRole, exists := c.Get("role") + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + if userRole.(string) != role && userRole.(string) != "admin" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) + return + } + + c.Next() + } +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go new file mode 100644 index 00000000..a059e9ee --- /dev/null +++ b/backend/internal/api/routes/routes.go @@ -0,0 +1,77 @@ +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/api/middleware" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services" +) + +// Register wires up API routes and performs automatic migrations. +func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { + // AutoMigrate all models for Issue #5 persistence layer + if err := db.AutoMigrate( + &models.ProxyHost{}, + &models.Location{}, + &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") + + // Auth routes + authService := services.NewAuthService(db, cfg) + authHandler := handlers.NewAuthHandler(authService) + authMiddleware := middleware.AuthMiddleware(authService) + + api.POST("/auth/login", authHandler.Login) + api.POST("/auth/register", authHandler.Register) + + protected := api.Group("/") + protected.Use(authMiddleware) + { + protected.POST("/auth/logout", authHandler.Logout) + protected.GET("/auth/me", authHandler.Me) + protected.POST("/auth/change-password", authHandler.ChangePassword) + } + + proxyHostHandler := handlers.NewProxyHostHandler(db) + proxyHostHandler.RegisterRoutes(api) + + remoteServerHandler := handlers.NewRemoteServerHandler(db) + remoteServerHandler.RegisterRoutes(api) + + userHandler := handlers.NewUserHandler(db) + userHandler.RegisterRoutes(api) + + // Certificate routes + // Use cfg.CaddyConfigDir + "/data" for cert service + caddyDataDir := cfg.CaddyConfigDir + "/data" + certService := services.NewCertificateService(caddyDataDir) + certHandler := handlers.NewCertificateHandler(certService) + api.GET("/certificates", certHandler.List) + + 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) +}