package handlers import ( "encoding/json" "fmt" "math" "net" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/api/middleware" "github.com/Wikid82/charon/backend/internal/caddy" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/network" "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/util" "github.com/Wikid82/charon/backend/internal/utils" ) // ProxyHostWarning represents an advisory warning about proxy host configuration. type ProxyHostWarning struct { Field string `json:"field"` Message string `json:"message"` } // ProxyHostResponse wraps a proxy host with optional advisory warnings. // Uses explicit fields to avoid exposing internal database IDs. type ProxyHostResponse struct { UUID string `json:"uuid"` Name string `json:"name"` DomainNames string `json:"domain_names"` ForwardScheme string `json:"forward_scheme"` ForwardHost string `json:"forward_host"` ForwardPort int `json:"forward_port"` SSLForced bool `json:"ssl_forced"` HTTP2Support bool `json:"http2_support"` HSTSEnabled bool `json:"hsts_enabled"` HSTSSubdomains bool `json:"hsts_subdomains"` BlockExploits bool `json:"block_exploits"` WebsocketSupport bool `json:"websocket_support"` Application string `json:"application"` Enabled bool `json:"enabled"` CertificateID *uint `json:"certificate_id"` Certificate *models.SSLCertificate `json:"certificate,omitempty"` AccessListID *uint `json:"access_list_id"` AccessList *models.AccessList `json:"access_list,omitempty"` Locations []models.Location `json:"locations"` AdvancedConfig string `json:"advanced_config"` AdvancedConfigBackup string `json:"advanced_config_backup"` ForwardAuthEnabled bool `json:"forward_auth_enabled"` WAFDisabled bool `json:"waf_disabled"` SecurityHeaderProfileID *uint `json:"security_header_profile_id"` SecurityHeaderProfile *models.SecurityHeaderProfile `json:"security_header_profile,omitempty"` SecurityHeadersEnabled bool `json:"security_headers_enabled"` SecurityHeadersCustom string `json:"security_headers_custom"` EnableStandardHeaders *bool `json:"enable_standard_headers,omitempty"` DNSProviderID *uint `json:"dns_provider_id,omitempty"` DNSProvider *models.DNSProvider `json:"dns_provider,omitempty"` UseDNSChallenge bool `json:"use_dns_challenge"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Warnings []ProxyHostWarning `json:"warnings,omitempty"` } // NewProxyHostResponse creates a ProxyHostResponse from a ProxyHost model. func NewProxyHostResponse(host *models.ProxyHost, warnings []ProxyHostWarning) ProxyHostResponse { return ProxyHostResponse{ UUID: host.UUID, Name: host.Name, DomainNames: host.DomainNames, ForwardScheme: host.ForwardScheme, ForwardHost: host.ForwardHost, ForwardPort: host.ForwardPort, SSLForced: host.SSLForced, HTTP2Support: host.HTTP2Support, HSTSEnabled: host.HSTSEnabled, HSTSSubdomains: host.HSTSSubdomains, BlockExploits: host.BlockExploits, WebsocketSupport: host.WebsocketSupport, Application: host.Application, Enabled: host.Enabled, CertificateID: host.CertificateID, Certificate: host.Certificate, AccessListID: host.AccessListID, AccessList: host.AccessList, Locations: host.Locations, AdvancedConfig: host.AdvancedConfig, AdvancedConfigBackup: host.AdvancedConfigBackup, ForwardAuthEnabled: host.ForwardAuthEnabled, WAFDisabled: host.WAFDisabled, SecurityHeaderProfileID: host.SecurityHeaderProfileID, SecurityHeaderProfile: host.SecurityHeaderProfile, SecurityHeadersEnabled: host.SecurityHeadersEnabled, SecurityHeadersCustom: host.SecurityHeadersCustom, EnableStandardHeaders: host.EnableStandardHeaders, DNSProviderID: host.DNSProviderID, DNSProvider: host.DNSProvider, UseDNSChallenge: host.UseDNSChallenge, CreatedAt: host.CreatedAt, UpdatedAt: host.UpdatedAt, Warnings: warnings, } } // generateForwardHostWarnings checks the forward_host value and returns advisory warnings. func generateForwardHostWarnings(forwardHost string) []ProxyHostWarning { var warnings []ProxyHostWarning if utils.IsDockerBridgeIP(forwardHost) { warnings = append(warnings, ProxyHostWarning{ Field: "forward_host", Message: "This looks like a Docker container IP address. Docker IPs can change when containers restart. Consider using the container name for more reliable connections.", }) } else if ip := net.ParseIP(forwardHost); ip != nil && network.IsPrivateIP(ip) { warnings = append(warnings, ProxyHostWarning{ Field: "forward_host", Message: "Using a private IP address. If this is a Docker container, the IP may change on restart. Container names are more reliable for Docker services.", }) } return warnings } // ProxyHostHandler handles CRUD operations for proxy hosts. type ProxyHostHandler struct { service *services.ProxyHostService db *gorm.DB caddyManager *caddy.Manager notificationService *services.NotificationService uptimeService *services.UptimeService } // safeIntToUint safely converts int to uint, returning false if negative (gosec G115) func safeIntToUint(i int) (uint, bool) { if i < 0 { return 0, false } return uint(i), true } // safeFloat64ToUint safely converts float64 to uint, returning false if invalid (gosec G115) func safeFloat64ToUint(f float64) (uint, bool) { if f < 0 || f != float64(uint(f)) { return 0, false } return uint(f), true } func parseNullableUintField(value any, fieldName string) (*uint, bool, error) { if value == nil { return nil, true, nil } switch v := value.(type) { case float64: if id, ok := safeFloat64ToUint(v); ok { return &id, true, nil } return nil, true, fmt.Errorf("invalid %s: unable to convert value %v of type %T to uint", fieldName, value, value) case int: if id, ok := safeIntToUint(v); ok { return &id, true, nil } return nil, true, fmt.Errorf("invalid %s: unable to convert value %v of type %T to uint", fieldName, value, value) case string: trimmed := strings.TrimSpace(v) if trimmed == "" { return nil, true, nil } n, err := strconv.ParseUint(trimmed, 10, 32) if err != nil { return nil, true, fmt.Errorf("invalid %s: unable to convert value %v of type %T to uint", fieldName, value, value) } id := uint(n) return &id, true, nil default: return nil, true, fmt.Errorf("invalid %s: unable to convert value %v of type %T to uint", fieldName, value, value) } } func (h *ProxyHostHandler) resolveAccessListReference(value any) (*uint, error) { if value == nil { return nil, nil } parsedID, _, parseErr := parseNullableUintField(value, "access_list_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 acl models.AccessList if err := h.db.Select("id").Where("uuid = ?", trimmed).First(&acl).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("access list not found") } return nil, fmt.Errorf("failed to resolve access list") } id := acl.ID return &id, nil } func (h *ProxyHostHandler) resolveSecurityHeaderProfileReference(value any) (*uint, error) { if value == nil { return nil, nil } parsedID, _, parseErr := parseNullableUintField(value, "security_header_profile_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 profile models.SecurityHeaderProfile if err := h.db.Select("id").Where("uuid = ?", trimmed).First(&profile).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("security header profile not found") } return nil, fmt.Errorf("failed to resolve security header profile") } id := profile.ID return &id, nil } func parseForwardPortField(value any) (int, error) { switch v := value.(type) { case float64: if v != math.Trunc(v) { return 0, fmt.Errorf("invalid forward_port: must be an integer") } port := int(v) if port < 1 || port > 65535 { return 0, fmt.Errorf("invalid forward_port: must be between 1 and 65535") } return port, nil case int: if v < 1 || v > 65535 { return 0, fmt.Errorf("invalid forward_port: must be between 1 and 65535") } return v, nil case string: trimmed := strings.TrimSpace(v) if trimmed == "" { return 0, fmt.Errorf("invalid forward_port: must be between 1 and 65535") } port, err := strconv.Atoi(trimmed) if err != nil { return 0, fmt.Errorf("invalid forward_port: must be an integer") } if port < 1 || port > 65535 { return 0, fmt.Errorf("invalid forward_port: must be between 1 and 65535") } return port, nil default: return 0, fmt.Errorf("invalid forward_port: unsupported type %T", value) } } // NewProxyHostHandler creates a new proxy host handler. func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService, uptimeService *services.UptimeService) *ProxyHostHandler { return &ProxyHostHandler{ service: services.NewProxyHostService(db), db: db, caddyManager: caddyManager, notificationService: ns, uptimeService: uptimeService, } } // 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) router.POST("/proxy-hosts/test", h.TestConnection) router.PUT("/proxy-hosts/bulk-update-acl", h.BulkUpdateACL) router.PUT("/proxy-hosts/bulk-update-security-headers", h.BulkUpdateSecurityHeaders) } // 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 payload map[string]any if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if rawAccessListRef, ok := payload["access_list_id"]; ok { resolvedAccessListID, resolveErr := h.resolveAccessListReference(rawAccessListRef) if resolveErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) return } payload["access_list_id"] = resolvedAccessListID } if rawSecurityHeaderRef, ok := payload["security_header_profile_id"]; ok { resolvedSecurityHeaderID, resolveErr := h.resolveSecurityHeaderProfileReference(rawSecurityHeaderRef) if resolveErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) return } payload["security_header_profile_id"] = resolvedSecurityHeaderID } payloadBytes, marshalErr := json.Marshal(payload) if marshalErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) return } var host models.ProxyHost if err := json.Unmarshal(payloadBytes, &host); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate and normalize advanced config if present if host.AdvancedConfig != "" { var parsed any if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "advanced_config must be valid Caddy JSON (not Caddyfile syntax). See https://caddyserver.com/docs/json/ for the correct format."}) return } parsed = caddy.NormalizeAdvancedConfig(parsed) if norm, err := json.Marshal(parsed); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()}) return } else { host.AdvancedConfig = string(norm) } } 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 } if h.caddyManager != nil { if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { // Rollback: delete the created host if config application fails middleware.GetRequestLogger(c).WithError(err).Error("Error applying config") if deleteErr := h.service.Delete(host.ID); deleteErr != nil { idStr := strconv.FormatUint(uint64(host.ID), 10) middleware.GetRequestLogger(c).WithField("host_id", idStr).WithError(deleteErr).Error("Critical: Failed to rollback host") } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) return } } // Send Notification if h.notificationService != nil { h.notificationService.SendExternal(c.Request.Context(), "proxy_host", "Proxy Host Created", "A new proxy host was successfully created.", map[string]any{ "Name": util.SanitizeForLog(host.Name), "Domains": util.SanitizeForLog(host.DomainNames), "Action": "created", }, ) } // Trigger immediate uptime monitor creation + health check (non-blocking) if h.uptimeService != nil { go h.uptimeService.SyncAndCheckForHost(host.ID) } // Generate advisory warnings for private/Docker IPs warnings := generateForwardHostWarnings(host.ForwardHost) // Return response with warnings if any if len(warnings) > 0 { c.JSON(http.StatusCreated, NewProxyHostResponse(&host, warnings)) return } c.JSON(http.StatusCreated, host) } // Get retrieves a proxy host by UUID. func (h *ProxyHostHandler) Get(c *gin.Context) { uuidStr := c.Param("uuid") host, err := h.service.GetByUUID(uuidStr) 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) { uuidStr := c.Param("uuid") host, err := h.service.GetByUUID(uuidStr) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"}) return } // Perform a partial update: only mutate fields present in payload var payload map[string]any if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Handle simple scalar fields by json tag names (snake_case) if v, ok := payload["name"].(string); ok { host.Name = v } if v, ok := payload["domain_names"].(string); ok { host.DomainNames = strings.TrimSpace(v) } if v, ok := payload["forward_scheme"].(string); ok { host.ForwardScheme = v } if v, ok := payload["forward_host"].(string); ok { host.ForwardHost = strings.TrimSpace(v) } if v, ok := payload["forward_port"]; ok { port, parseErr := parseForwardPortField(v) if parseErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": parseErr.Error()}) return } host.ForwardPort = port } if v, ok := payload["ssl_forced"].(bool); ok { host.SSLForced = v } if v, ok := payload["http2_support"].(bool); ok { host.HTTP2Support = v } if v, ok := payload["hsts_enabled"].(bool); ok { host.HSTSEnabled = v } if v, ok := payload["hsts_subdomains"].(bool); ok { host.HSTSSubdomains = v } if v, ok := payload["block_exploits"].(bool); ok { host.BlockExploits = v } if v, ok := payload["websocket_support"].(bool); ok { host.WebsocketSupport = v } if v, ok := payload["application"].(string); ok { host.Application = v } if v, ok := payload["enabled"].(bool); ok { host.Enabled = v } // Handle enable_standard_headers (nullable bool - uses pointer pattern like certificate_id) if v, ok := payload["enable_standard_headers"]; ok { if v == nil { host.EnableStandardHeaders = nil // Explicit null → use default behavior } else if b, ok := v.(bool); ok { host.EnableStandardHeaders = &b // Explicit true/false } } // Handle forward_auth_enabled (regular bool) if v, ok := payload["forward_auth_enabled"].(bool); ok { host.ForwardAuthEnabled = v } // Handle waf_disabled (regular bool) if v, ok := payload["waf_disabled"].(bool); ok { host.WAFDisabled = v } // Nullable foreign keys if v, ok := payload["certificate_id"]; ok { parsedID, _, parseErr := parseNullableUintField(v, "certificate_id") if parseErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": parseErr.Error()}) return } host.CertificateID = parsedID } if v, ok := payload["access_list_id"]; ok { resolvedAccessListID, resolveErr := h.resolveAccessListReference(v) if resolveErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) return } host.AccessListID = resolvedAccessListID } if v, ok := payload["dns_provider_id"]; ok { parsedID, _, parseErr := parseNullableUintField(v, "dns_provider_id") if parseErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": parseErr.Error()}) return } host.DNSProviderID = parsedID } if v, ok := payload["use_dns_challenge"].(bool); ok { host.UseDNSChallenge = v } // Security Header Profile: update only if provided if v, ok := payload["security_header_profile_id"]; ok { resolvedSecurityHeaderID, resolveErr := h.resolveSecurityHeaderProfileReference(v) if resolveErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) return } host.SecurityHeaderProfileID = resolvedSecurityHeaderID } // Locations: replace only if provided if v, ok := payload["locations"].([]any); ok { // Rebind to []models.Location b, _ := json.Marshal(v) var locs []models.Location if err := json.Unmarshal(b, &locs); err == nil { // Ensure UUIDs exist for any new location entries for i := range locs { if locs[i].UUID == "" { locs[i].UUID = uuid.New().String() } } host.Locations = locs } else { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid locations payload"}) return } } // Advanced config: normalize if provided and changed if v, ok := payload["advanced_config"].(string); ok { if v != "" && v != host.AdvancedConfig { var parsed any if err := json.Unmarshal([]byte(v), &parsed); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "advanced_config must be valid Caddy JSON (not Caddyfile syntax). See https://caddyserver.com/docs/json/ for the correct format."}) return } parsed = caddy.NormalizeAdvancedConfig(parsed) if norm, err := json.Marshal(parsed); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()}) return } else { // Backup previous host.AdvancedConfigBackup = host.AdvancedConfig host.AdvancedConfig = string(norm) } } else if v == "" { // allow clearing advanced config host.AdvancedConfigBackup = host.AdvancedConfig host.AdvancedConfig = "" } } if err := h.service.Update(host); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if h.caddyManager != nil { if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) return } } // Sync associated uptime monitor with updated proxy host values if h.uptimeService != nil { if err := h.uptimeService.SyncMonitorForHost(host.ID); err != nil { middleware.GetRequestLogger(c).WithError(err).WithField("host_id", host.ID).Warn("Failed to sync uptime monitor for host") // Don't fail the request if sync fails - the host update succeeded } } // Generate advisory warnings for private/Docker IPs warnings := generateForwardHostWarnings(host.ForwardHost) // Return response with warnings if any if len(warnings) > 0 { c.JSON(http.StatusOK, NewProxyHostResponse(host, warnings)) return } c.JSON(http.StatusOK, host) } // Delete removes a proxy host. func (h *ProxyHostHandler) Delete(c *gin.Context) { uuidStr := c.Param("uuid") host, err := h.service.GetByUUID(uuidStr) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"}) return } // Always clean up associated uptime monitors when deleting a proxy host. // The query param delete_uptime=true is kept for backward compatibility but // cleanup now runs unconditionally to prevent orphaned monitors. if h.uptimeService != nil { var monitors []models.UptimeMonitor if err := h.uptimeService.DB.Where("proxy_host_id = ?", host.ID).Find(&monitors).Error; err == nil { for _, m := range monitors { _ = h.uptimeService.DeleteMonitor(m.ID) } } } if err := h.service.Delete(host.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if h.caddyManager != nil { if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()}) return } } // Send Notification if h.notificationService != nil { h.notificationService.SendExternal(c.Request.Context(), "proxy_host", "Proxy Host Deleted", "A proxy host was successfully deleted.", map[string]any{ "Name": host.Name, "Action": "deleted", }, ) } c.JSON(http.StatusOK, gin.H{"message": "proxy host deleted"}) } // TestConnection checks if the proxy host is reachable. func (h *ProxyHostHandler) TestConnection(c *gin.Context) { var req struct { ForwardHost string `json:"forward_host" binding:"required"` ForwardPort int `json:"forward_port" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.service.TestConnection(req.ForwardHost, req.ForwardPort); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Connection successful"}) } // BulkUpdateACL applies or removes an access list to multiple proxy hosts. func (h *ProxyHostHandler) BulkUpdateACL(c *gin.Context) { var req struct { HostUUIDs []string `json:"host_uuids" binding:"required"` AccessListID *uint `json:"access_list_id"` // nil means remove ACL } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(req.HostUUIDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "host_uuids cannot be empty"}) return } updated := 0 errors := []map[string]string{} for _, hostUUID := range req.HostUUIDs { host, err := h.service.GetByUUID(hostUUID) if err != nil { errors = append(errors, map[string]string{ "uuid": hostUUID, "error": "proxy host not found", }) continue } host.AccessListID = req.AccessListID if err := h.service.Update(host); err != nil { errors = append(errors, map[string]string{ "uuid": hostUUID, "error": err.Error(), }) continue } updated++ } // Apply Caddy config once for all updates if updated > 0 && h.caddyManager != nil { if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to apply configuration: " + err.Error(), "updated": updated, "errors": errors, }) return } } c.JSON(http.StatusOK, gin.H{ "updated": updated, "errors": errors, }) } // BulkUpdateSecurityHeadersRequest represents the request body for bulk security header updates. type BulkUpdateSecurityHeadersRequest struct { HostUUIDs []string `json:"host_uuids" binding:"required"` SecurityHeaderProfileID *uint `json:"security_header_profile_id"` // nil means remove profile } // BulkUpdateSecurityHeaders applies or removes a security header profile to multiple proxy hosts. func (h *ProxyHostHandler) BulkUpdateSecurityHeaders(c *gin.Context) { var req BulkUpdateSecurityHeadersRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(req.HostUUIDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "host_uuids cannot be empty"}) return } // Validate profile exists if provided if req.SecurityHeaderProfileID != nil { var profile models.SecurityHeaderProfile if err := h.service.DB().First(&profile, *req.SecurityHeaderProfileID).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusBadRequest, gin.H{"error": "security header profile not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } // Start transaction for atomic updates tx := h.service.DB().Begin() defer func() { if r := recover(); r != nil { tx.Rollback() } }() updated := 0 errors := []map[string]string{} for _, hostUUID := range req.HostUUIDs { var host models.ProxyHost if err := tx.Where("uuid = ?", hostUUID).First(&host).Error; err != nil { errors = append(errors, map[string]string{ "uuid": hostUUID, "error": "proxy host not found", }) continue } // Update security header profile ID host.SecurityHeaderProfileID = req.SecurityHeaderProfileID if err := tx.Model(&host).Where("id = ?", host.ID).Select("SecurityHeaderProfileID").Updates(&host).Error; err != nil { errors = append(errors, map[string]string{ "uuid": hostUUID, "error": err.Error(), }) continue } updated++ } // Commit transaction only if all updates succeeded if len(errors) > 0 && updated == 0 { tx.Rollback() c.JSON(http.StatusBadRequest, gin.H{ "error": "All updates failed", "updated": updated, "errors": errors, }) return } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction: " + err.Error()}) return } // Apply Caddy config once for all updates if updated > 0 && h.caddyManager != nil { if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to apply configuration: " + err.Error(), "updated": updated, "errors": errors, }) return } } c.JSON(http.StatusOK, gin.H{ "updated": updated, "errors": errors, }) }