diff --git a/agent/go.mod b/agent/go.mod index 2b72a9460..b2992f75f 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -11,7 +11,10 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect golang.org/x/sys v0.44.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/agent/go.sum b/agent/go.sum index c52e4a833..eb90792f7 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -1,18 +1,27 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/api/handlers/proxy_group_handler.go b/backend/internal/api/handlers/proxy_group_handler.go new file mode 100644 index 000000000..da6a5fd7c --- /dev/null +++ b/backend/internal/api/handlers/proxy_group_handler.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" +) + +// ProxyGroupHandler handles CRUD operations for proxy groups. +type ProxyGroupHandler struct { + service *services.ProxyGroupService + db *gorm.DB +} + +// NewProxyGroupHandler creates a new ProxyGroupHandler. +func NewProxyGroupHandler(db *gorm.DB) *ProxyGroupHandler { + return &ProxyGroupHandler{ + service: services.NewProxyGroupService(db), + db: db, + } +} + +// RegisterRoutes registers proxy group routes on the given router group. +func (h *ProxyGroupHandler) RegisterRoutes(router *gin.RouterGroup) { + router.GET("/proxy-groups", h.List) + router.POST("/proxy-groups", h.Create) + router.GET("/proxy-groups/:uuid", h.Get) + router.PUT("/proxy-groups/:uuid", h.Update) + router.DELETE("/proxy-groups/:uuid", h.Delete) +} + +// List returns all proxy groups ordered by name. +func (h *ProxyGroupHandler) List(c *gin.Context) { + groups, err := h.service.List() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, groups) +} + +// Create creates a new proxy group. +func (h *ProxyGroupHandler) Create(c *gin.Context) { + var payload struct { + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + } + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + group := models.ProxyGroup{ + Name: strings.TrimSpace(payload.Name), + Description: payload.Description, + Color: payload.Color, + } + if group.Color == "" { + group.Color = "#6366f1" + } + + if err := h.service.Create(&group); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, group) +} + +// Get returns a single proxy group by UUID, including host count. +func (h *ProxyGroupHandler) Get(c *gin.Context) { + group, err := h.service.GetByUUID(c.Param("uuid")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "proxy group not found"}) + return + } + count, _ := h.service.GetHostCount(group.ID) + c.JSON(http.StatusOK, gin.H{ + "uuid": group.UUID, + "name": group.Name, + "description": group.Description, + "color": group.Color, + "host_count": count, + "created_at": group.CreatedAt, + "updated_at": group.UpdatedAt, + }) +} + +// Update updates an existing proxy group by UUID. +func (h *ProxyGroupHandler) Update(c *gin.Context) { + group, err := h.service.GetByUUID(c.Param("uuid")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "proxy group not found"}) + return + } + + var payload struct { + Name *string `json:"name"` + Description *string `json:"description"` + Color *string `json:"color"` + } + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if payload.Name != nil { + trimmed := strings.TrimSpace(*payload.Name) + if trimmed == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name cannot be empty"}) + return + } + group.Name = trimmed + } + if payload.Description != nil { + group.Description = *payload.Description + } + if payload.Color != nil { + group.Color = *payload.Color + } + + if err := h.service.Update(group); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, group) +} + +// Delete removes a proxy group by UUID, clearing host assignments. +func (h *ProxyGroupHandler) Delete(c *gin.Context) { + group, err := h.service.GetByUUID(c.Param("uuid")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "proxy group not found"}) + return + } + if err := h.service.Delete(group.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/api/handlers/proxy_group_handler_test.go b/backend/internal/api/handlers/proxy_group_handler_test.go new file mode 100644 index 000000000..f1a5eb77b --- /dev/null +++ b/backend/internal/api/handlers/proxy_group_handler_test.go @@ -0,0 +1,155 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupProxyGroupHandlerRouter(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyGroup{}, &models.ProxyHost{})) + + gin.SetMode(gin.TestMode) + router := gin.New() + h := NewProxyGroupHandler(db) + grp := router.Group("/") + h.RegisterRoutes(grp) + return router, db +} + +func doRequest(router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { + var b []byte + if body != nil { + b, _ = json.Marshal(body) + } + req, _ := http.NewRequest(method, path, bytes.NewBuffer(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +func TestProxyGroupHandler_List_Empty(t *testing.T) { + router, _ := setupProxyGroupHandlerRouter(t) + w := doRequest(router, http.MethodGet, "/proxy-groups", nil) + assert.Equal(t, http.StatusOK, w.Code) + var result []any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Empty(t, result) +} + +func TestProxyGroupHandler_List_WithGroups(t *testing.T) { + router, db := setupProxyGroupHandlerRouter(t) + require.NoError(t, db.Create(&models.ProxyGroup{Name: "Beta"}).Error) + require.NoError(t, db.Create(&models.ProxyGroup{Name: "Alpha"}).Error) + + w := doRequest(router, http.MethodGet, "/proxy-groups", nil) + assert.Equal(t, http.StatusOK, w.Code) + var result []map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Len(t, result, 2) + assert.Equal(t, "Alpha", result[0]["name"]) +} + +func TestProxyGroupHandler_Create_Valid(t *testing.T) { + router, _ := setupProxyGroupHandlerRouter(t) + w := doRequest(router, http.MethodPost, "/proxy-groups", map[string]any{ + "name": "Production", + "description": "Prod services", + "color": "#ff0000", + }) + assert.Equal(t, http.StatusCreated, w.Code) + var result map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Equal(t, "Production", result["name"]) + assert.NotEmpty(t, result["uuid"]) +} + +func TestProxyGroupHandler_Create_EmptyName_400(t *testing.T) { + router, _ := setupProxyGroupHandlerRouter(t) + w := doRequest(router, http.MethodPost, "/proxy-groups", map[string]any{"name": ""}) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestProxyGroupHandler_Create_DefaultColor(t *testing.T) { + router, _ := setupProxyGroupHandlerRouter(t) + w := doRequest(router, http.MethodPost, "/proxy-groups", map[string]any{"name": "NoColor"}) + assert.Equal(t, http.StatusCreated, w.Code) + var result map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Equal(t, "#6366f1", result["color"]) +} + +func TestProxyGroupHandler_Get_Found(t *testing.T) { + router, db := setupProxyGroupHandlerRouter(t) + group := &models.ProxyGroup{Name: "Find Me", Color: "#abc"} + require.NoError(t, db.Create(group).Error) + + w := doRequest(router, http.MethodGet, "/proxy-groups/"+group.UUID, nil) + assert.Equal(t, http.StatusOK, w.Code) + var result map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Equal(t, "Find Me", result["name"]) + assert.Equal(t, float64(0), result["host_count"]) +} + +func TestProxyGroupHandler_Get_NotFound_404(t *testing.T) { + router, _ := setupProxyGroupHandlerRouter(t) + w := doRequest(router, http.MethodGet, "/proxy-groups/nonexistent-uuid", nil) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestProxyGroupHandler_Update_PartialFields(t *testing.T) { + router, db := setupProxyGroupHandlerRouter(t) + group := &models.ProxyGroup{Name: "Old Name", Color: "#111"} + require.NoError(t, db.Create(group).Error) + + newName := "New Name" + w := doRequest(router, http.MethodPut, "/proxy-groups/"+group.UUID, map[string]any{ + "name": newName, + }) + assert.Equal(t, http.StatusOK, w.Code) + var result map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Equal(t, "New Name", result["name"]) +} + +func TestProxyGroupHandler_Update_EmptyName_400(t *testing.T) { + router, db := setupProxyGroupHandlerRouter(t) + group := &models.ProxyGroup{Name: "Valid"} + require.NoError(t, db.Create(group).Error) + + w := doRequest(router, http.MethodPut, "/proxy-groups/"+group.UUID, map[string]any{"name": " "}) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestProxyGroupHandler_Delete_204(t *testing.T) { + router, db := setupProxyGroupHandlerRouter(t) + group := &models.ProxyGroup{Name: "Delete Me"} + require.NoError(t, db.Create(group).Error) + + w := doRequest(router, http.MethodDelete, "/proxy-groups/"+group.UUID, nil) + assert.Equal(t, http.StatusNoContent, w.Code) + + var count int64 + db.Model(&models.ProxyGroup{}).Count(&count) + assert.Equal(t, int64(0), count) +} + +func TestProxyGroupHandler_Delete_NotFound_404(t *testing.T) { + router, _ := setupProxyGroupHandlerRouter(t) + w := doRequest(router, http.MethodDelete, "/proxy-groups/nonexistent-uuid", nil) + assert.Equal(t, http.StatusNotFound, w.Code) +} diff --git a/backend/internal/api/handlers/proxy_host_handler.go b/backend/internal/api/handlers/proxy_host_handler.go index 95b42dd21..9eb435bde 100644 --- a/backend/internal/api/handlers/proxy_host_handler.go +++ b/backend/internal/api/handlers/proxy_host_handler.go @@ -63,6 +63,8 @@ type ProxyHostResponse struct { DNSProviderID *uint `json:"dns_provider_id,omitempty"` DNSProvider *models.DNSProvider `json:"dns_provider,omitempty"` UseDNSChallenge bool `json:"use_dns_challenge"` + ProxyGroupID *uint `json:"proxy_group_id,omitempty"` + ProxyGroup *models.ProxyGroup `json:"proxy_group,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Warnings []ProxyHostWarning `json:"warnings,omitempty"` @@ -102,6 +104,8 @@ func NewProxyHostResponse(host *models.ProxyHost, warnings []ProxyHostWarning) P DNSProviderID: host.DNSProviderID, DNSProvider: host.DNSProvider, UseDNSChallenge: host.UseDNSChallenge, + ProxyGroupID: host.ProxyGroupID, + ProxyGroup: host.ProxyGroup, CreatedAt: host.CreatedAt, UpdatedAt: host.UpdatedAt, Warnings: warnings, @@ -248,6 +252,38 @@ func (h *ProxyHostHandler) resolveSecurityHeaderProfileReference(value any) (*ui return &id, nil } +func (h *ProxyHostHandler) resolveProxyGroupReference(value any) (*uint, error) { + if value == nil { + return nil, nil + } + + parsedID, parseErr := parseNullableUintField(value, "proxy_group_id") + if parseErr == nil { + return parsedID, nil + } + + uuidValue, isString := value.(string) + if !isString { + return nil, parseErr + } + + trimmed := strings.TrimSpace(uuidValue) + if trimmed == "" { + return nil, nil + } + + var pg models.ProxyGroup + if err := h.db.Select("id").Where("uuid = ?", trimmed).First(&pg).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("proxy group not found") + } + return nil, fmt.Errorf("failed to resolve proxy group") + } + + id := pg.ID + return &id, nil +} + func (h *ProxyHostHandler) resolveCertificateReference(value any) (*uint, error) { if value == nil { return nil, nil @@ -374,6 +410,15 @@ func (h *ProxyHostHandler) Create(c *gin.Context) { payload["security_header_profile_id"] = resolvedSecurityHeaderID } + if rawGroupRef, ok := payload["proxy_group_id"]; ok { + resolvedGroupID, resolveErr := h.resolveProxyGroupReference(rawGroupRef) + if resolveErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) + return + } + payload["proxy_group_id"] = resolvedGroupID + } + if rawCertRef, ok := payload["certificate_id"]; ok { resolvedCertID, resolveErr := h.resolveCertificateReference(rawCertRef) if resolveErr != nil { @@ -580,6 +625,15 @@ func (h *ProxyHostHandler) Update(c *gin.Context) { host.AccessListID = resolvedAccessListID } + if v, ok := payload["proxy_group_id"]; ok { + resolvedGroupID, resolveErr := h.resolveProxyGroupReference(v) + if resolveErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) + return + } + host.ProxyGroupID = resolvedGroupID + } + if v, ok := payload["dns_provider_id"]; ok { parsedID, parseErr := parseNullableUintField(v, "dns_provider_id") if parseErr != nil { diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 1ed53775b..19335ed42 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -98,6 +98,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg // AutoMigrate all models for Issue #5 persistence layer if err := db.AutoMigrate( + &models.ProxyGroup{}, // must precede ProxyHost (FK dependency) &models.ProxyHost{}, &models.Location{}, &models.CaddyConfig{}, @@ -759,6 +760,9 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService) proxyHostHandler.RegisterRoutes(management) + proxyGroupHandler := handlers.NewProxyGroupHandler(db) + proxyGroupHandler.RegisterRoutes(management) + remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService) remoteServerHandler.RegisterRoutes(management) } diff --git a/backend/internal/models/proxy_group.go b/backend/internal/models/proxy_group.go new file mode 100644 index 000000000..803a88182 --- /dev/null +++ b/backend/internal/models/proxy_group.go @@ -0,0 +1,27 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ProxyGroup represents a named group for organizing proxy hosts. +type ProxyGroup struct { + ID uint `json:"-" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex;not null"` + Name string `json:"name" gorm:"not null;index"` + Description string `json:"description" gorm:"type:text"` + Color string `json:"color" gorm:"default:#6366f1"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// BeforeCreate assigns a UUID if one has not been set. +func (g *ProxyGroup) BeforeCreate(tx *gorm.DB) (err error) { + if g.UUID == "" { + g.UUID = uuid.New().String() + } + return +} diff --git a/backend/internal/models/proxy_host.go b/backend/internal/models/proxy_host.go index d31bfe0b4..c82b1eda3 100644 --- a/backend/internal/models/proxy_host.go +++ b/backend/internal/models/proxy_host.go @@ -58,6 +58,10 @@ type ProxyHost struct { DNSProvider *DNSProvider `json:"dns_provider,omitempty" gorm:"foreignKey:DNSProviderID"` UseDNSChallenge bool `json:"use_dns_challenge" gorm:"default:false"` + // Proxy Group assignment + ProxyGroupID *uint `json:"proxy_group_id,omitempty" gorm:"index"` + ProxyGroup *ProxyGroup `json:"proxy_group,omitempty" gorm:"foreignKey:ProxyGroupID"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/backend/internal/services/proxy_group_service.go b/backend/internal/services/proxy_group_service.go new file mode 100644 index 000000000..ec299e232 --- /dev/null +++ b/backend/internal/services/proxy_group_service.go @@ -0,0 +1,79 @@ +package services + +import ( + "errors" + "fmt" + + "gorm.io/gorm" + + "github.com/Wikid82/charon/backend/internal/models" +) + +var ( + ErrProxyGroupNotFound = errors.New("proxy group not found") + ErrProxyGroupNameEmpty = errors.New("proxy group name cannot be empty") +) + +// ProxyGroupService handles CRUD operations for proxy groups. +type ProxyGroupService struct { + db *gorm.DB +} + +// NewProxyGroupService creates a new ProxyGroupService. +func NewProxyGroupService(db *gorm.DB) *ProxyGroupService { + return &ProxyGroupService{db: db} +} + +// Create persists a new proxy group. +func (s *ProxyGroupService) Create(group *models.ProxyGroup) error { + if group.Name == "" { + return ErrProxyGroupNameEmpty + } + return s.db.Create(group).Error +} + +// List returns all proxy groups ordered by name ascending. +func (s *ProxyGroupService) List() ([]models.ProxyGroup, error) { + var groups []models.ProxyGroup + if err := s.db.Order("name asc").Find(&groups).Error; err != nil { + return nil, err + } + return groups, nil +} + +// GetByUUID returns the proxy group with the given UUID. +func (s *ProxyGroupService) GetByUUID(uuidStr string) (*models.ProxyGroup, error) { + var group models.ProxyGroup + result := s.db.Where("uuid = ?", uuidStr).Limit(1).Find(&group) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, ErrProxyGroupNotFound + } + return &group, nil +} + +// Update saves changes to an existing proxy group. +func (s *ProxyGroupService) Update(group *models.ProxyGroup) error { + return s.db.Save(group).Error +} + +// Delete removes the proxy group and unassigns any hosts that belonged to it. +func (s *ProxyGroupService) Delete(id uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&models.ProxyHost{}). + Where("proxy_group_id = ?", id). + Update("proxy_group_id", nil).Error; err != nil { + return fmt.Errorf("failed to unassign proxy hosts: %w", err) + } + return tx.Delete(&models.ProxyGroup{}, id).Error + }) +} + +// GetHostCount returns the number of proxy hosts assigned to the given group. +func (s *ProxyGroupService) GetHostCount(id uint) (int64, error) { + var count int64 + err := s.db.Model(&models.ProxyHost{}).Where("proxy_group_id = ?", id).Count(&count).Error + return count, err +} diff --git a/backend/internal/services/proxy_group_service_test.go b/backend/internal/services/proxy_group_service_test.go new file mode 100644 index 000000000..f984e8c88 --- /dev/null +++ b/backend/internal/services/proxy_group_service_test.go @@ -0,0 +1,142 @@ +package services + +import ( + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupProxyGroupTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.ProxyGroup{}, &models.ProxyHost{})) + return db +} + +func TestProxyGroupService_Create(t *testing.T) { + db := setupProxyGroupTestDB(t) + svc := NewProxyGroupService(db) + + group := &models.ProxyGroup{Name: "Production", Color: "#ff0000"} + err := svc.Create(group) + require.NoError(t, err) + assert.NotEmpty(t, group.UUID) + assert.NotZero(t, group.ID) +} + +func TestProxyGroupService_Create_EmptyName(t *testing.T) { + db := setupProxyGroupTestDB(t) + svc := NewProxyGroupService(db) + + err := svc.Create(&models.ProxyGroup{Name: ""}) + assert.ErrorIs(t, err, ErrProxyGroupNameEmpty) +} + +func TestProxyGroupService_List(t *testing.T) { + db := setupProxyGroupTestDB(t) + svc := NewProxyGroupService(db) + + require.NoError(t, svc.Create(&models.ProxyGroup{Name: "Zebra"})) + require.NoError(t, svc.Create(&models.ProxyGroup{Name: "Alpha"})) + + groups, err := svc.List() + require.NoError(t, err) + require.Len(t, groups, 2) + assert.Equal(t, "Alpha", groups[0].Name) + assert.Equal(t, "Zebra", groups[1].Name) +} + +func TestProxyGroupService_GetByUUID(t *testing.T) { + db := setupProxyGroupTestDB(t) + svc := NewProxyGroupService(db) + + created := &models.ProxyGroup{Name: "Test Group"} + require.NoError(t, svc.Create(created)) + + t.Run("found", func(t *testing.T) { + got, err := svc.GetByUUID(created.UUID) + require.NoError(t, err) + assert.Equal(t, created.Name, got.Name) + }) + + t.Run("not found", func(t *testing.T) { + _, err := svc.GetByUUID("non-existent-uuid") + assert.ErrorIs(t, err, ErrProxyGroupNotFound) + }) +} + +func TestProxyGroupService_Update(t *testing.T) { + db := setupProxyGroupTestDB(t) + svc := NewProxyGroupService(db) + + group := &models.ProxyGroup{Name: "Original", Color: "#111111"} + require.NoError(t, svc.Create(group)) + + group.Name = "Updated" + group.Description = "New description" + group.Color = "#222222" + require.NoError(t, svc.Update(group)) + + got, err := svc.GetByUUID(group.UUID) + require.NoError(t, err) + assert.Equal(t, "Updated", got.Name) + assert.Equal(t, "New description", got.Description) + assert.Equal(t, "#222222", got.Color) +} + +func TestProxyGroupService_Delete_ClearsHostAssignments(t *testing.T) { + db := setupProxyGroupTestDB(t) + svc := NewProxyGroupService(db) + + group := &models.ProxyGroup{Name: "ToDelete"} + require.NoError(t, svc.Create(group)) + + host := &models.ProxyHost{ + UUID: "test-host-uuid", + DomainNames: "example.com", + ForwardScheme: "http", + ForwardHost: "localhost", + ForwardPort: 80, + ProxyGroupID: &group.ID, + } + require.NoError(t, db.Create(host).Error) + + require.NoError(t, svc.Delete(group.ID)) + + var updated models.ProxyHost + require.NoError(t, db.First(&updated, host.ID).Error) + assert.Nil(t, updated.ProxyGroupID) + + var count int64 + db.Model(&models.ProxyGroup{}).Count(&count) + assert.Equal(t, int64(0), count) +} + +func TestProxyGroupService_GetHostCount(t *testing.T) { + db := setupProxyGroupTestDB(t) + svc := NewProxyGroupService(db) + + group := &models.ProxyGroup{Name: "Counted"} + require.NoError(t, svc.Create(group)) + + for i := range 3 { + h := &models.ProxyHost{ + UUID: "host-uuid-" + string(rune('a'+i)), + DomainNames: "test.com", + ForwardScheme: "http", + ForwardHost: "localhost", + ForwardPort: 80 + i, + ProxyGroupID: &group.ID, + } + require.NoError(t, db.Create(h).Error) + } + + count, err := svc.GetHostCount(group.ID) + require.NoError(t, err) + assert.Equal(t, int64(3), count) +} diff --git a/backend/internal/services/proxyhost_service.go b/backend/internal/services/proxyhost_service.go index ded58f089..a104e00d8 100644 --- a/backend/internal/services/proxyhost_service.go +++ b/backend/internal/services/proxyhost_service.go @@ -227,7 +227,7 @@ func (s *ProxyHostService) GetByID(id uint) (*models.ProxyHost, error) { // GetByUUID finds a proxy host by UUID. func (s *ProxyHostService) GetByUUID(uuidStr string) (*models.ProxyHost, error) { var host models.ProxyHost - if err := s.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Preload("SecurityHeaderProfile").Where("uuid = ?", uuidStr).First(&host).Error; err != nil { + if err := s.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Preload("SecurityHeaderProfile").Preload("ProxyGroup").Where("uuid = ?", uuidStr).First(&host).Error; err != nil { return nil, err } return &host, nil @@ -236,7 +236,7 @@ func (s *ProxyHostService) GetByUUID(uuidStr string) (*models.ProxyHost, error) // List returns all proxy hosts. func (s *ProxyHostService) List() ([]models.ProxyHost, error) { var hosts []models.ProxyHost - if err := s.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Preload("SecurityHeaderProfile").Order("updated_at desc").Find(&hosts).Error; err != nil { + if err := s.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Preload("SecurityHeaderProfile").Preload("ProxyGroup").Order("updated_at desc").Find(&hosts).Error; err != nil { return nil, err } return hosts, nil diff --git a/backend/internal/services/proxyhost_service_group_test.go b/backend/internal/services/proxyhost_service_group_test.go new file mode 100644 index 000000000..a495b8cd3 --- /dev/null +++ b/backend/internal/services/proxyhost_service_group_test.go @@ -0,0 +1,74 @@ +package services + +import ( + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupProxyHostGroupTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.ProxyGroup{}, + &models.ProxyHost{}, + &models.Location{}, + &models.SSLCertificate{}, + &models.AccessList{}, + &models.SecurityHeaderProfile{}, + &models.DNSProvider{}, + )) + return db +} + +func TestProxyHostService_List_PreloadsGroup(t *testing.T) { + db := setupProxyHostGroupTestDB(t) + svc := NewProxyHostService(db) + + group := &models.ProxyGroup{Name: "Web Services", Color: "#00ff00"} + require.NoError(t, db.Create(group).Error) + + host := &models.ProxyHost{ + UUID: "host-list-uuid", + DomainNames: "example.com", + ForwardScheme: "http", + ForwardHost: "localhost", + ForwardPort: 80, + ProxyGroupID: &group.ID, + } + require.NoError(t, db.Create(host).Error) + + hosts, err := svc.List() + require.NoError(t, err) + require.Len(t, hosts, 1) + require.NotNil(t, hosts[0].ProxyGroup) + assert.Equal(t, "Web Services", hosts[0].ProxyGroup.Name) +} + +func TestProxyHostService_GetByUUID_PreloadsGroup(t *testing.T) { + db := setupProxyHostGroupTestDB(t) + svc := NewProxyHostService(db) + + group := &models.ProxyGroup{Name: "API Services", Color: "#0000ff"} + require.NoError(t, db.Create(group).Error) + + host := &models.ProxyHost{ + UUID: "host-getbyuuid-uuid", + DomainNames: "api.example.com", + ForwardScheme: "https", + ForwardHost: "localhost", + ForwardPort: 443, + ProxyGroupID: &group.ID, + } + require.NoError(t, db.Create(host).Error) + + got, err := svc.GetByUUID("host-getbyuuid-uuid") + require.NoError(t, err) + require.NotNil(t, got.ProxyGroup) + assert.Equal(t, "API Services", got.ProxyGroup.Name) +} diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 9816b8cbc..acf10fd88 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,722 +1,1073 @@ -# CI Fix — ESLint + Backend Test Failures +# Proxy Groups for Better Organization — Implementation Specification -**Branch**: `fix/ci-eslint-backend-test` -**PR**: Targets `development` -**Date**: 2026-05-29 +**Feature**: Issue #254 — Proxy Groups +**Branch**: `feature/proxy_groups` +**Status**: Planning +**Complexity**: Medium --- -> **Archived**: The previous spec (CI Fix — Vitest `invites a new user` Failure) has been -> superseded. This document covers the active CI failures. +## 1. Executive Summary + +Proxy Groups allows users to organize their proxy hosts into named, color-coded groups (folders). A group is a lightweight container that holds zero or more proxy hosts. Groups appear in the Proxy Hosts page as collapsible sections, letting users manage large sets of hosts without losing them in a flat list. + +**Goals:** +- Create, rename, recolor, and delete groups via the UI +- Assign proxy hosts to groups (one group per host, optional) +- Display hosts grouped on the Proxy Hosts page (ungrouped hosts shown last) +- Full CRUD API for groups +- Bulk-assign hosts to a group (multi-select in table → assign to group) + +**Non-Goals (explicitly out of scope):** +- Nested/hierarchical groups +- Group-level enable/disable toggle (hosts retain individual control) +- Group-level certificate or access list assignment +- Sorting/reordering groups beyond alphabetical (future) +- Tags or multi-group membership --- -## 1. Introduction +## 2. Research Findings -### Overview +### 2.1 Backend Architecture -Multiple CI jobs in `quality-checks.yml` are failing. The failures split across two domains: +**Module**: `github.com/Wikid82/charon/backend` -1. **Frontend ESLint** — Blocking job; 7 distinct lint violations across 5 source files and 1 - test file. -2. **Backend test suite** — Blocking job (via `scripts/go-test-coverage.sh`); - `TestSecurityHandler_UpsertRuleSet_XSSInContent` fails when the full handler package test suite - runs in parallel. +**GORM Pattern** (confirmed from `models/domain.go`, `models/access_list.go`): +- Internal `ID uint` hidden with `json:"-"` +- External `UUID string` with `json:"uuid" gorm:"uniqueIndex;not null"` +- `BeforeCreate` hook for UUID generation +- No soft delete for this type of model (same pattern as AccessList) -Backend **lint** violations exist but are configured with `continue-on-error: true` and are -therefore non-blocking. They are explicitly excluded from this plan. +**Routes Registration** (`backend/internal/api/routes/routes.go`): +- AutoMigrate list at ~line 110 — needs `&models.ProxyGroup{}` added +- Handler registration pattern at ~line 760-762 (after ProxyHostHandler): + ```go + proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService) + proxyHostHandler.RegisterRoutes(management) + ``` +- Complex handlers use `RegisterRoutes(management *gin.RouterGroup)` pattern -### Objectives +**ProxyHost FK Pattern** (confirmed from `models/uptime.go`, `models/proxy_host.go`): +```go +ProxyGroupID *uint `json:"proxy_group_id,omitempty" gorm:"index"` +ProxyGroup *ProxyGroup `json:"proxy_group,omitempty" gorm:"foreignKey:ProxyGroupID"` +``` -- Restore all CI jobs to green in a single PR composed of 6 ordered, reviewable commits. -- No feature changes; this is a pure fix/refactor to make the test suite and linter pass. +**ProxyHostService.List** (`services/proxyhost_service.go` line 237): +```go +s.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Preload("SecurityHeaderProfile").Order("updated_at desc").Find(&hosts) +``` +Must add `Preload("ProxyGroup")`. ---- +**ProxyHostService.GetByUUID** (line 228): +```go +s.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Preload("SecurityHeaderProfile").Where("uuid = ?", uuidStr).First(&host) +``` +Must add `Preload("ProxyGroup")`. -## 2. Research Findings +**No existing proxy group code** — zero references in backend or frontend confirmed via search. -### 2.1 CI Workflow +### 2.2 Frontend Architecture -**File**: `.github/workflows/quality-checks.yml` +**React Query pattern** (`hooks/useProxyHosts.ts`): +```ts +const query = useQuery({ queryKey: QUERY_KEY, queryFn: getProxyHosts }); +const mutation = useMutation({ mutationFn: ..., onSuccess: () => queryClient.invalidateQueries(...) }); +``` -| Job | Script / Command | Blocking? | -|-----|-----------------|-----------| -| Backend tests + coverage | `scripts/go-test-coverage.sh` | **Yes** | -| Backend lint | `golangci/golangci-lint-action@v9.2.0` with `continue-on-error: true` | No | -| Frontend ESLint | `npm run lint` | **Yes** | -| Frontend type-check | `npm run type-check` | Yes | -| Frontend unit tests | `scripts/frontend-test-coverage.sh` | Yes | +**API client pattern** (`api/accessLists.ts`): +```ts +export const accessListsApi = { + async list(): Promise { ... }, + async get(uuid: string): Promise { ... }, + async create(data: CreateRequest): Promise { ... }, + async update(uuid: string, data: Partial): Promise { ... }, + async delete(uuid: string): Promise { ... }, +}; +``` -`scripts/go-test-coverage.sh` captures `GO_TEST_STATUS` from the test run, applies the coverage -gate, and at the end bubbles up any non-zero test exit code (script lines 290-295). A single -failing test therefore causes the job to exit non-zero. +**Routing** (`App.tsx`): No new top-level route needed — proxy groups are managed from within the Proxy Hosts page via modals. -### 2.2 Frontend ESLint — Root Causes +**Navigation** (`components/Layout.tsx`): No nav change needed — groups are a sub-feature of Proxy Hosts. -**ESLint plugins in use** (`frontend/eslint.config.js`): -`jsx-a11y`, `security`, `react-refresh`, `@vitest/eslint-plugin` (aliased as `vitest`) +**UI Components available**: `Dialog`, `Button`, `Badge`, `Input`, `Textarea`, `DataTable`, `EmptyState`, `Card` -#### Failure 1 — Duplicate devDependencies +--- -`frontend/package.json` contains three devDependency keys that each appear twice: +## 3. Database Schema Changes -| Duplicate key | Affected lines | -|---|---| -| `@typescript-eslint/eslint-plugin` | first occurrence + second set ~lines 74-76 | -| `@typescript-eslint/parser` | same | -| `@typescript-eslint/utils` | same | +### 3.1 New Table: `proxy_groups` -npm treats a duplicate key as a parse-time error that prevents consistent lock-file resolution, -causing the ESLint run to fail with a dependency error. +| Column | Type | Constraints | Notes | +|--------------|----------|--------------------------|----------------------------| +| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Internal, never serialized | +| `uuid` | TEXT | UNIQUE NOT NULL | External identifier | +| `name` | TEXT | NOT NULL, INDEX | Display name | +| `description`| TEXT | | Optional | +| `color` | TEXT | DEFAULT `#6366f1` | Hex color for badge | +| `created_at` | DATETIME | | | +| `updated_at` | DATETIME | | | -#### Failure 2 — Unsafe regex (`security/detect-unsafe-regex`) +### 3.2 Modified Table: `proxy_hosts` -**File**: `frontend/src/components/CredentialManager.tsx` +Add one new nullable column: -`validateZoneFilter` uses a regex with nested quantifiers. The rule flags it as a potential ReDoS -vector. The regex is intentional; the risk is acceptable for this context. +| Column | Type | Constraints | Notes | +|-----------------|---------|---------------------------|---------------------| +| `proxy_group_id` | INTEGER | INDEX, FK → proxy_groups(id) | NULL = ungrouped | -**Fix**: Add an inline `// eslint-disable-next-line security/detect-unsafe-regex` with a -justification comment immediately before the regex literal. +GORM `foreignKey:ProxyGroupID` wires the association. SQLite `ON DELETE SET NULL` is **not** used via GORM tag — the `ProxyGroupService.Delete` method explicitly clears host associations before deleting the group for consistent cross-database behavior. -#### Failure 3 — Non-component exports (`react-refresh/only-export-components`) +--- -**File**: `frontend/src/components/CertificateList.tsx` — lines 20 and 24 +## 4. Backend Implementation Plan -```tsx -export function isInUse(cert: Certificate): boolean { ... } // line 20 -export function isDeletable(cert: Certificate): boolean { ... } // line 24 -``` +### 4.1 New File: `backend/internal/models/proxy_group.go` -`react-refresh` requires component files export **only React components**. Exporting plain utility -functions breaks HMR and triggers this rule. +```go +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ProxyGroup struct { + ID uint `json:"-" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex;not null"` + Name string `json:"name" gorm:"not null;index"` + Description string `json:"description" gorm:"type:text"` + Color string `json:"color" gorm:"default:#6366f1"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} -**Known import sites** (must be updated after move): -- `frontend/src/components/__tests__/CertificateList.test.tsx` line 8 +func (g *ProxyGroup) BeforeCreate(tx *gorm.DB) (err error) { + if g.UUID == "" { + g.UUID = uuid.New().String() + } + return +} +``` -**Fix**: Move both functions to a new `frontend/src/utils/certificateUtils.ts`; update all import -sites; keep internal calls in `CertificateList.tsx` via the new import. +**Notes:** +- No `gorm.DeletedAt` — groups are hard-deleted (same as AccessList model) +- Color defaults to indigo (`#6366f1`) matching the project's design system +- No `ProxyHosts []ProxyHost` back-reference to avoid N+1 on group list -#### Failure 4 — Label on non-labelable elements (`jsx-a11y/label-has-associated-control`) +### 4.2 Modify: `backend/internal/models/proxy_host.go` -5 violations across 3 files: +Add after the `DNSProvider *DNSProvider` field, before the next section: -| File | Line | Element | Problem | -|---|---|---|---| -| `frontend/src/components/CSPBuilder.tsx` | ~326 | `