Files
Charon/backend/handlers.html
GitHub Actions 197e2bf672 Add comprehensive tests for security and user handlers, enhancing coverage
- Introduced tests for the security handler, covering UpdateConfig, GetConfig, ListDecisions, CreateDecision, UpsertRuleSet, DeleteRuleSet, Enable, and Disable functionalities.
- Added tests for user handler methods including GetSetupStatus, Setup, RegenerateAPIKey, GetProfile, and UpdateProfile, ensuring robust error handling and validation.
- Implemented path traversal and injection tests in the WAF configuration to prevent security vulnerabilities.
- Updated the manager to sanitize ruleset names by stripping potentially harmful characters and patterns.
2025-12-04 17:54:17 +00:00

4290 lines
199 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>handlers: Go Coverage Report</title>
<style>
body {
background: black;
color: rgb(80, 80, 80);
}
body, pre, #legend span {
font-family: Menlo, monospace;
font-weight: bold;
}
#topbar {
background: black;
position: fixed;
top: 0; left: 0; right: 0;
height: 42px;
border-bottom: 1px solid rgb(80, 80, 80);
}
#content {
margin-top: 50px;
}
#nav, #legend {
float: left;
margin-left: 10px;
}
#legend {
margin-top: 12px;
}
#nav {
margin-top: 10px;
}
#legend span {
margin: 0 5px;
}
.cov0 { color: rgb(192, 0, 0) }
.cov1 { color: rgb(128, 128, 128) }
.cov2 { color: rgb(116, 140, 131) }
.cov3 { color: rgb(104, 152, 134) }
.cov4 { color: rgb(92, 164, 137) }
.cov5 { color: rgb(80, 176, 140) }
.cov6 { color: rgb(68, 188, 143) }
.cov7 { color: rgb(56, 200, 146) }
.cov8 { color: rgb(44, 212, 149) }
.cov9 { color: rgb(32, 224, 152) }
.cov10 { color: rgb(20, 236, 155) }
</style>
</head>
<body>
<div id="topbar">
<div id="nav">
<select id="files">
<option value="file0">github.com/Wikid82/charon/backend/internal/api/handlers/access_list_handler.go (97.4%)</option>
<option value="file1">github.com/Wikid82/charon/backend/internal/api/handlers/auth_handler.go (95.1%)</option>
<option value="file2">github.com/Wikid82/charon/backend/internal/api/handlers/backup_handler.go (78.0%)</option>
<option value="file3">github.com/Wikid82/charon/backend/internal/api/handlers/certificate_handler.go (82.4%)</option>
<option value="file4">github.com/Wikid82/charon/backend/internal/api/handlers/crowdsec_exec.go (92.3%)</option>
<option value="file5">github.com/Wikid82/charon/backend/internal/api/handlers/crowdsec_handler.go (77.7%)</option>
<option value="file6">github.com/Wikid82/charon/backend/internal/api/handlers/docker_handler.go (100.0%)</option>
<option value="file7">github.com/Wikid82/charon/backend/internal/api/handlers/domain_handler.go (84.6%)</option>
<option value="file8">github.com/Wikid82/charon/backend/internal/api/handlers/feature_flags_handler.go (86.0%)</option>
<option value="file9">github.com/Wikid82/charon/backend/internal/api/handlers/health_handler.go (77.8%)</option>
<option value="file10">github.com/Wikid82/charon/backend/internal/api/handlers/import_handler.go (73.7%)</option>
<option value="file11">github.com/Wikid82/charon/backend/internal/api/handlers/logs_handler.go (73.3%)</option>
<option value="file12">github.com/Wikid82/charon/backend/internal/api/handlers/notification_handler.go (87.5%)</option>
<option value="file13">github.com/Wikid82/charon/backend/internal/api/handlers/notification_provider_handler.go (82.4%)</option>
<option value="file14">github.com/Wikid82/charon/backend/internal/api/handlers/notification_template_handler.go (72.5%)</option>
<option value="file15">github.com/Wikid82/charon/backend/internal/api/handlers/proxy_host_handler.go (83.2%)</option>
<option value="file16">github.com/Wikid82/charon/backend/internal/api/handlers/remote_server_handler.go (71.4%)</option>
<option value="file17">github.com/Wikid82/charon/backend/internal/api/handlers/sanitize.go (85.7%)</option>
<option value="file18">github.com/Wikid82/charon/backend/internal/api/handlers/security_handler.go (80.3%)</option>
<option value="file19">github.com/Wikid82/charon/backend/internal/api/handlers/security_handler_test_fixed.go (0.0%)</option>
<option value="file20">github.com/Wikid82/charon/backend/internal/api/handlers/settings_handler.go (81.8%)</option>
<option value="file21">github.com/Wikid82/charon/backend/internal/api/handlers/system_handler.go (82.6%)</option>
<option value="file22">github.com/Wikid82/charon/backend/internal/api/handlers/testdb.go (88.9%)</option>
<option value="file23">github.com/Wikid82/charon/backend/internal/api/handlers/update_handler.go (100.0%)</option>
<option value="file24">github.com/Wikid82/charon/backend/internal/api/handlers/uptime_handler.go (94.9%)</option>
<option value="file25">github.com/Wikid82/charon/backend/internal/api/handlers/user_handler.go (83.7%)</option>
</select>
</div>
<div id="legend">
<span>not tracked</span>
<span class="cov0">no coverage</span>
<span class="cov1">low coverage</span>
<span class="cov2">*</span>
<span class="cov3">*</span>
<span class="cov4">*</span>
<span class="cov5">*</span>
<span class="cov6">*</span>
<span class="cov7">*</span>
<span class="cov8">*</span>
<span class="cov9">*</span>
<span class="cov10">high coverage</span>
</div>
</div>
<div id="content">
<pre class="file" id="file0" style="display: none">package handlers
import (
"net/http"
"strconv"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AccessListHandler struct {
service *services.AccessListService
}
func NewAccessListHandler(db *gorm.DB) *AccessListHandler <span class="cov10" title="21">{
return &amp;AccessListHandler{
service: services.NewAccessListService(db),
}
}</span>
// Create handles POST /api/v1/access-lists
func (h *AccessListHandler) Create(c *gin.Context) <span class="cov6" title="6">{
var acl models.AccessList
if err := c.ShouldBindJSON(&amp;acl); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov5" title="5">if err := h.service.Create(&amp;acl); err != nil </span><span class="cov3" title="2">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov4" title="3">c.JSON(http.StatusCreated, acl)</span>
}
// List handles GET /api/v1/access-lists
func (h *AccessListHandler) List(c *gin.Context) <span class="cov3" title="2">{
acls, err := h.service.List()
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, acls)</span>
}
// Get handles GET /api/v1/access-lists/:id
func (h *AccessListHandler) Get(c *gin.Context) <span class="cov5" title="4">{
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}</span>
<span class="cov4" title="3">acl, err := h.service.GetByID(uint(id))
if err != nil </span><span class="cov3" title="2">{
if err == services.ErrAccessListNotFound </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return</span>
}
<span class="cov1" title="1">c.JSON(http.StatusOK, acl)</span>
}
// Update handles PUT /api/v1/access-lists/:id
func (h *AccessListHandler) Update(c *gin.Context) <span class="cov5" title="5">{
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}</span>
<span class="cov5" title="4">var updates models.AccessList
if err := c.ShouldBindJSON(&amp;updates); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov4" title="3">if err := h.service.Update(uint(id), &amp;updates); err != nil </span><span class="cov3" title="2">{
if err == services.ErrAccessListNotFound </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return</span>
}
// Fetch updated record
<span class="cov1" title="1">acl, _ := h.service.GetByID(uint(id))
c.JSON(http.StatusOK, acl)</span>
}
// Delete handles DELETE /api/v1/access-lists/:id
func (h *AccessListHandler) Delete(c *gin.Context) <span class="cov5" title="5">{
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}</span>
<span class="cov5" title="4">if err := h.service.Delete(uint(id)); err != nil </span><span class="cov4" title="3">{
if err == services.ErrAccessListNotFound </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}</span>
<span class="cov3" title="2">if err == services.ErrAccessListInUse </span><span class="cov1" title="1">{
c.JSON(http.StatusConflict, gin.H{"error": "access list is in use by proxy hosts"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return</span>
}
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"message": "access list deleted"})</span>
}
// TestIP handles POST /api/v1/access-lists/:id/test
func (h *AccessListHandler) TestIP(c *gin.Context) <span class="cov7" title="10">{
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}</span>
<span class="cov7" title="9">var req struct {
IPAddress string `json:"ip_address" binding:"required"`
}
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov7" title="8">allowed, reason, err := h.service.TestIP(uint(id), req.IPAddress)
if err != nil </span><span class="cov3" title="2">{
if err == services.ErrAccessListNotFound </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}</span>
<span class="cov1" title="1">if err == services.ErrInvalidIPAddress </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP address"})
return
}</span>
<span class="cov0" title="0">c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return</span>
}
<span class="cov6" title="6">c.JSON(http.StatusOK, gin.H{
"allowed": allowed,
"reason": reason,
})</span>
}
// GetTemplates handles GET /api/v1/access-lists/templates
func (h *AccessListHandler) GetTemplates(c *gin.Context) <span class="cov1" title="1">{
templates := h.service.GetTemplates()
c.JSON(http.StatusOK, templates)
}</span>
</pre>
<pre class="file" id="file1" style="display: none">package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
authService *services.AuthService
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler <span class="cov10" title="11">{
return &amp;AuthHandler{authService: authService}
}</span>
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
func (h *AuthHandler) Login(c *gin.Context) <span class="cov7" title="6">{
var req LoginRequest
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov7" title="5">token, err := h.authService.Login(req.Email, req.Password)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}</span>
// Set cookie
<span class="cov6" title="4">c.SetCookie("auth_token", token, 3600*24, "/", "", false, true) // Secure should be true in prod
c.JSON(http.StatusOK, gin.H{"token": token})</span>
}
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) <span class="cov3" title="2">{
var req RegisterRequest
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov3" title="2">user, err := h.authService.Register(req.Email, req.Password, req.Name)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusCreated, user)</span>
}
func (h *AuthHandler) Logout(c *gin.Context) <span class="cov1" title="1">{
c.SetCookie("auth_token", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}</span>
func (h *AuthHandler) Me(c *gin.Context) <span class="cov3" title="2">{
userID, _ := c.Get("userID")
role, _ := c.Get("role")
u, err := h.authService.GetUserByID(userID.(uint))
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"role": role,
"name": u.Name,
"email": u.Email,
})</span>
}
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) <span class="cov6" title="4">{
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov5" title="3">userID, exists := c.Get("userID")
if !exists </span><span class="cov1" title="1">{
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}</span>
<span class="cov3" title="2">if err := h.authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})</span>
}
</pre>
<pre class="file" id="file2" style="display: none">package handlers
import (
"net/http"
"os"
"path/filepath"
"github.com/Wikid82/charon/backend/internal/api/middleware"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
"github.com/gin-gonic/gin"
)
type BackupHandler struct {
service *services.BackupService
}
func NewBackupHandler(service *services.BackupService) *BackupHandler <span class="cov10" title="12">{
return &amp;BackupHandler{service: service}
}</span>
func (h *BackupHandler) List(c *gin.Context) <span class="cov7" title="6">{
backups, err := h.service.ListBackups()
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backups"})
return
}</span>
<span class="cov7" title="6">c.JSON(http.StatusOK, backups)</span>
}
func (h *BackupHandler) Create(c *gin.Context) <span class="cov8" title="8">{
filename, err := h.service.CreateBackup()
if err != nil </span><span class="cov0" title="0">{
middleware.GetRequestLogger(c).WithField("action", "create_backup").WithError(err).Error("Failed to create backup")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup: " + err.Error()})
return
}</span>
<span class="cov8" title="8">middleware.GetRequestLogger(c).WithField("action", "create_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).Info("Backup created successfully")
c.JSON(http.StatusCreated, gin.H{"filename": filename, "message": "Backup created successfully"})</span>
}
func (h *BackupHandler) Delete(c *gin.Context) <span class="cov6" title="5">{
filename := c.Param("filename")
if err := h.service.DeleteBackup(filename); err != nil </span><span class="cov4" title="3">{
if os.IsNotExist(err) </span><span class="cov4" title="3">{
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}</span>
<span class="cov0" title="0">c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup"})
return</span>
}
<span class="cov3" title="2">c.JSON(http.StatusOK, gin.H{"message": "Backup deleted"})</span>
}
func (h *BackupHandler) Download(c *gin.Context) <span class="cov6" title="5">{
filename := c.Param("filename")
path, err := h.service.GetBackupPath(filename)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov6" title="5">if _, err := os.Stat(path); os.IsNotExist(err) </span><span class="cov3" title="2">{
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}</span>
<span class="cov4" title="3">c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(path)</span>
}
func (h *BackupHandler) Restore(c *gin.Context) <span class="cov7" title="6">{
filename := c.Param("filename")
if err := h.service.RestoreBackup(filename); err != nil </span><span class="cov4" title="3">{
middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).WithError(err).Error("Failed to restore backup")
if os.IsNotExist(err) </span><span class="cov3" title="2">{
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restore backup: " + err.Error()})
return</span>
}
<span class="cov4" title="3">middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).Info("Backup restored successfully")
// In a real scenario, we might want to trigger a restart here
c.JSON(http.StatusOK, gin.H{"message": "Backup restored successfully. Please restart the container."})</span>
}
</pre>
<pre class="file" id="file3" style="display: none">package handlers
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
// BackupServiceInterface defines the contract for backup service operations
type BackupServiceInterface interface {
CreateBackup() (string, error)
ListBackups() ([]services.BackupFile, error)
DeleteBackup(filename string) error
GetBackupPath(filename string) (string, error)
RestoreBackup(filename string) error
}
type CertificateHandler struct {
service *services.CertificateService
backupService BackupServiceInterface
notificationService *services.NotificationService
}
func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler <span class="cov10" title="15">{
return &amp;CertificateHandler{
service: service,
backupService: backupService,
notificationService: ns,
}
}</span>
func (h *CertificateHandler) List(c *gin.Context) <span class="cov4" title="3">{
certs, err := h.service.ListCertificates()
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, certs)</span>
}
type UploadCertificateRequest struct {
Name string `form:"name" binding:"required"`
Certificate string `form:"certificate"` // PEM content
PrivateKey string `form:"private_key"` // PEM content
}
func (h *CertificateHandler) Upload(c *gin.Context) <span class="cov5" title="4">{
// Handle multipart form
name := c.PostForm("name")
if name == "" </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}</span>
// Read files
<span class="cov4" title="3">certFile, err := c.FormFile("certificate_file")
if err != nil </span><span class="cov3" title="2">{
c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"})
return
}</span>
<span class="cov1" title="1">keyFile, err := c.FormFile("key_file")
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required"})
return
}</span>
// Open and read content
<span class="cov1" title="1">certSrc, err := certFile.Open()
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"})
return
}</span>
<span class="cov1" title="1">defer func() </span><span class="cov1" title="1">{ _ = certSrc.Close() }</span>()
<span class="cov1" title="1">keySrc, err := keyFile.Open()
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
return
}</span>
<span class="cov1" title="1">defer func() </span><span class="cov1" title="1">{ _ = keySrc.Close() }</span>()
// Read to string
// Limit size to avoid DoS (e.g. 1MB)
<span class="cov1" title="1">certBytes := make([]byte, 1024*1024)
n, _ := certSrc.Read(certBytes)
certPEM := string(certBytes[:n])
keyBytes := make([]byte, 1024*1024)
n, _ = keySrc.Read(keyBytes)
keyPEM := string(keyBytes[:n])
cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
// Send Notification
<span class="cov1" title="1">if h.notificationService != nil </span><span class="cov0" title="0">{
h.notificationService.SendExternal(c.Request.Context(),
"cert",
"Certificate Uploaded",
fmt.Sprintf("Certificate %s uploaded", util.SanitizeForLog(cert.Name)),
map[string]interface{}{
"Name": util.SanitizeForLog(cert.Name),
"Domains": util.SanitizeForLog(cert.Domains),
"Action": "uploaded",
},
)
}</span>
<span class="cov1" title="1">c.JSON(http.StatusCreated, cert)</span>
}
func (h *CertificateHandler) Delete(c *gin.Context) <span class="cov7" title="8">{
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}</span>
// Check if certificate is in use before proceeding
<span class="cov7" title="7">inUse, err := h.service.IsCertificateInUse(uint(id))
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"})
return
}</span>
<span class="cov6" title="6">if inUse </span><span class="cov3" title="2">{
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
return
}</span>
// Create backup before deletion
<span class="cov5" title="4">if h.backupService != nil </span><span class="cov3" title="2">{
if _, err := h.backupService.CreateBackup(); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup before deletion"})
return
}</span>
}
// Proceed with deletion
<span class="cov4" title="3">if err := h.service.DeleteCertificate(uint(id)); err != nil </span><span class="cov1" title="1">{
if err == services.ErrCertInUse </span><span class="cov0" title="0">{
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return</span>
}
// Send Notification
<span class="cov3" title="2">if h.notificationService != nil </span><span class="cov0" title="0">{
h.notificationService.SendExternal(c.Request.Context(),
"cert",
"Certificate Deleted",
fmt.Sprintf("Certificate ID %d deleted", id),
map[string]interface{}{
"ID": id,
"Action": "deleted",
},
)
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})</span>
}
</pre>
<pre class="file" id="file4" style="display: none">package handlers
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
)
// DefaultCrowdsecExecutor implements CrowdsecExecutor using OS processes.
type DefaultCrowdsecExecutor struct {
}
func NewDefaultCrowdsecExecutor() *DefaultCrowdsecExecutor <span class="cov8" title="9">{ return &amp;DefaultCrowdsecExecutor{} }</span>
func (e *DefaultCrowdsecExecutor) pidFile(configDir string) string <span class="cov10" title="14">{
return filepath.Join(configDir, "crowdsec.pid")
}</span>
func (e *DefaultCrowdsecExecutor) Start(ctx context.Context, binPath, configDir string) (int, error) <span class="cov3" title="2">{
cmd := exec.CommandContext(ctx, binPath, "--config-dir", configDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil </span><span class="cov1" title="1">{
return 0, err
}</span>
<span class="cov1" title="1">pid := cmd.Process.Pid
// write pid file
if err := os.WriteFile(e.pidFile(configDir), []byte(strconv.Itoa(pid)), 0o644); err != nil </span><span class="cov0" title="0">{
return pid, fmt.Errorf("failed to write pid file: %w", err)
}</span>
// wait in background
<span class="cov1" title="1">go func() </span><span class="cov1" title="1">{
_ = cmd.Wait()
_ = os.Remove(e.pidFile(configDir))
}</span>()
<span class="cov1" title="1">return pid, nil</span>
}
func (e *DefaultCrowdsecExecutor) Stop(ctx context.Context, configDir string) error <span class="cov5" title="4">{
b, err := os.ReadFile(e.pidFile(configDir))
if err != nil </span><span class="cov1" title="1">{
return fmt.Errorf("pid file read: %w", err)
}</span>
<span class="cov4" title="3">pid, err := strconv.Atoi(string(b))
if err != nil </span><span class="cov1" title="1">{
return fmt.Errorf("invalid pid: %w", err)
}</span>
<span class="cov3" title="2">proc, err := os.FindProcess(pid)
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov3" title="2">if err := proc.Signal(syscall.SIGTERM); err != nil </span><span class="cov1" title="1">{
return err
}</span>
// best-effort remove pid file
<span class="cov1" title="1">_ = os.Remove(e.pidFile(configDir))
return nil</span>
}
func (e *DefaultCrowdsecExecutor) Status(ctx context.Context, configDir string) (bool, int, error) <span class="cov6" title="5">{
b, err := os.ReadFile(e.pidFile(configDir))
if err != nil </span><span class="cov3" title="2">{
return false, 0, nil
}</span>
<span class="cov4" title="3">pid, err := strconv.Atoi(string(b))
if err != nil </span><span class="cov1" title="1">{
return false, 0, nil
}</span>
// Check process exists
<span class="cov3" title="2">proc, err := os.FindProcess(pid)
if err != nil </span><span class="cov0" title="0">{
return false, pid, nil
}</span>
// Sending signal 0 is not portable on Windows, but OK for Linux containers
<span class="cov3" title="2">if err := proc.Signal(syscall.Signal(0)); err != nil </span><span class="cov1" title="1">{
return false, pid, nil
}</span>
<span class="cov1" title="1">return true, pid, nil</span>
}
</pre>
<pre class="file" id="file5" style="display: none">package handlers
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"github.com/Wikid82/charon/backend/internal/logger"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Executor abstracts starting/stopping CrowdSec so tests can mock it.
type CrowdsecExecutor interface {
Start(ctx context.Context, binPath, configDir string) (int, error)
Stop(ctx context.Context, configDir string) error
Status(ctx context.Context, configDir string) (running bool, pid int, err error)
}
// CrowdsecHandler manages CrowdSec process and config imports.
type CrowdsecHandler struct {
DB *gorm.DB
Executor CrowdsecExecutor
BinPath string
DataDir string
}
func NewCrowdsecHandler(db *gorm.DB, exec CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler <span class="cov10" title="21">{
return &amp;CrowdsecHandler{DB: db, Executor: exec, BinPath: binPath, DataDir: dataDir}
}</span>
// Start starts the CrowdSec process.
func (h *CrowdsecHandler) Start(c *gin.Context) <span class="cov3" title="2">{
ctx := c.Request.Context()
pid, err := h.Executor.Start(ctx, h.BinPath, h.DataDir)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"status": "started", "pid": pid})</span>
}
// Stop stops the CrowdSec process.
func (h *CrowdsecHandler) Stop(c *gin.Context) <span class="cov3" title="2">{
ctx := c.Request.Context()
if err := h.Executor.Stop(ctx, h.DataDir); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"status": "stopped"})</span>
}
// Status returns simple running state.
func (h *CrowdsecHandler) Status(c *gin.Context) <span class="cov3" title="2">{
ctx := c.Request.Context()
running, pid, err := h.Executor.Status(ctx, h.DataDir)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"running": running, "pid": pid})</span>
}
// ImportConfig accepts a tar.gz or zip upload and extracts into DataDir (backing up existing config).
func (h *CrowdsecHandler) ImportConfig(c *gin.Context) <span class="cov4" title="3">{
file, err := c.FormFile("file")
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}</span>
// Save to temp file
<span class="cov3" title="2">tmpDir := os.TempDir()
tmpPath := filepath.Join(tmpDir, fmt.Sprintf("crowdsec-import-%d", time.Now().UnixNano()))
if err := os.MkdirAll(tmpPath, 0o755); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp dir"})
return
}</span>
<span class="cov3" title="2">dst := filepath.Join(tmpPath, file.Filename)
if err := c.SaveUploadedFile(file, dst); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}</span>
// For safety, do minimal validation: ensure file non-empty
<span class="cov3" title="2">fi, err := os.Stat(dst)
if err != nil || fi.Size() == 0 </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "empty upload"})
return
}</span>
// Backup current config
<span class="cov3" title="2">backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405")
if _, err := os.Stat(h.DataDir); err == nil </span><span class="cov3" title="2">{
_ = os.Rename(h.DataDir, backupDir)
}</span>
// Create target dir
<span class="cov3" title="2">if err := os.MkdirAll(h.DataDir, 0o755); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config dir"})
return
}</span>
// For now, simply copy uploaded file into data dir for operator to handle extraction
<span class="cov3" title="2">target := filepath.Join(h.DataDir, file.Filename)
in, err := os.Open(dst)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open temp file"})
return
}</span>
<span class="cov3" title="2">defer in.Close()
out, err := os.Create(target)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create target file"})
return
}</span>
<span class="cov3" title="2">defer out.Close()
if _, err := io.Copy(out, in); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config"})
return
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, gin.H{"status": "imported", "backup": backupDir})</span>
}
// ExportConfig creates a tar.gz archive of the CrowdSec data directory and streams it
// back to the client as a downloadable file.
func (h *CrowdsecHandler) ExportConfig(c *gin.Context) <span class="cov3" title="2">{
// Ensure DataDir exists
if _, err := os.Stat(h.DataDir); os.IsNotExist(err) </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "crowdsec config not found"})
return
}</span>
// Create a gzip writer and tar writer that stream directly to the response
<span class="cov1" title="1">c.Header("Content-Type", "application/gzip")
filename := fmt.Sprintf("crowdsec-config-%s.tar.gz", time.Now().Format("20060102-150405"))
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
gw := gzip.NewWriter(c.Writer)
defer func() </span><span class="cov1" title="1">{
if err := gw.Close(); err != nil </span><span class="cov0" title="0">{
logger.Log().WithError(err).Warn("Failed to close gzip writer")
}</span>
}()
<span class="cov1" title="1">tw := tar.NewWriter(gw)
defer func() </span><span class="cov1" title="1">{
if err := tw.Close(); err != nil </span><span class="cov0" title="0">{
logger.Log().WithError(err).Warn("Failed to close tar writer")
}</span>
}()
// Walk the DataDir and add files to the archive
<span class="cov1" title="1">err := filepath.Walk(h.DataDir, func(path string, info os.FileInfo, err error) error </span><span class="cov5" title="4">{
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov5" title="4">if info.IsDir() </span><span class="cov3" title="2">{
return nil
}</span>
<span class="cov3" title="2">rel, err := filepath.Rel(h.DataDir, path)
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
// Open file
<span class="cov3" title="2">f, err := os.Open(path)
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov3" title="2">defer f.Close()
hdr := &amp;tar.Header{
Name: rel,
Size: info.Size(),
Mode: int64(info.Mode()),
ModTime: info.ModTime(),
}
if err := tw.WriteHeader(hdr); err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov3" title="2">if _, err := io.Copy(tw, f); err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov3" title="2">return nil</span>
})
<span class="cov1" title="1">if err != nil </span><span class="cov0" title="0">{
// If any error occurred while creating the archive, return 500
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
}
// ListFiles returns a flat list of files under the CrowdSec DataDir.
func (h *CrowdsecHandler) ListFiles(c *gin.Context) <span class="cov4" title="3">{
var files []string
if _, err := os.Stat(h.DataDir); os.IsNotExist(err) </span><span class="cov1" title="1">{
c.JSON(http.StatusOK, gin.H{"files": files})
return
}</span>
<span class="cov3" title="2">err := filepath.Walk(h.DataDir, func(path string, info os.FileInfo, err error) error </span><span class="cov5" title="5">{
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov5" title="5">if !info.IsDir() </span><span class="cov3" title="2">{
rel, err := filepath.Rel(h.DataDir, path)
if err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov3" title="2">files = append(files, rel)</span>
}
<span class="cov5" title="5">return nil</span>
})
<span class="cov3" title="2">if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, gin.H{"files": files})</span>
}
// ReadFile returns the contents of a specific file under DataDir. Query param 'path' required.
func (h *CrowdsecHandler) ReadFile(c *gin.Context) <span class="cov5" title="5">{
rel := c.Query("path")
if rel == "" </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
return
}</span>
<span class="cov5" title="4">clean := filepath.Clean(rel)
// prevent directory traversal
p := filepath.Join(h.DataDir, clean)
if !strings.HasPrefix(p, filepath.Clean(h.DataDir)) </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}</span>
<span class="cov4" title="3">data, err := os.ReadFile(p)
if err != nil </span><span class="cov1" title="1">{
if os.IsNotExist(err) </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}</span>
<span class="cov0" title="0">c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return</span>
}
<span class="cov3" title="2">c.JSON(http.StatusOK, gin.H{"content": string(data)})</span>
}
// WriteFile writes content to a file under the CrowdSec DataDir, creating a backup before doing so.
// JSON body: { "path": "relative/path.conf", "content": "..." }
func (h *CrowdsecHandler) WriteFile(c *gin.Context) <span class="cov5" title="5">{
var payload struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&amp;payload); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}</span>
<span class="cov5" title="4">if payload.Path == "" </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
return
}</span>
<span class="cov4" title="3">clean := filepath.Clean(payload.Path)
p := filepath.Join(h.DataDir, clean)
if !strings.HasPrefix(p, filepath.Clean(h.DataDir)) </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}</span>
// Backup existing DataDir
<span class="cov3" title="2">backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405")
if _, err := os.Stat(h.DataDir); err == nil </span><span class="cov3" title="2">{
if err := os.Rename(h.DataDir, backupDir); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup"})
return
}</span>
}
// Recreate DataDir and write file
<span class="cov3" title="2">if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare dir"})
return
}</span>
<span class="cov3" title="2">if err := os.WriteFile(p, []byte(payload.Content), 0o644); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
return
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir})</span>
}
// RegisterRoutes registers crowdsec admin routes under protected group
func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) <span class="cov10" title="21">{
rg.POST("/admin/crowdsec/start", h.Start)
rg.POST("/admin/crowdsec/stop", h.Stop)
rg.GET("/admin/crowdsec/status", h.Status)
rg.POST("/admin/crowdsec/import", h.ImportConfig)
rg.GET("/admin/crowdsec/export", h.ExportConfig)
rg.GET("/admin/crowdsec/files", h.ListFiles)
rg.GET("/admin/crowdsec/file", h.ReadFile)
rg.POST("/admin/crowdsec/file", h.WriteFile)
}</span>
</pre>
<pre class="file" id="file6" style="display: none">package handlers
import (
"fmt"
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type DockerHandler struct {
dockerService *services.DockerService
remoteServerService *services.RemoteServerService
}
func NewDockerHandler(dockerService *services.DockerService, remoteServerService *services.RemoteServerService) *DockerHandler <span class="cov10" title="6">{
return &amp;DockerHandler{
dockerService: dockerService,
remoteServerService: remoteServerService,
}
}</span>
func (h *DockerHandler) RegisterRoutes(r *gin.RouterGroup) <span class="cov9" title="5">{
r.GET("/docker/containers", h.ListContainers)
}</span>
func (h *DockerHandler) ListContainers(c *gin.Context) <span class="cov7" title="4">{
host := c.Query("host")
serverID := c.Query("server_id")
// If server_id is provided, look up the remote server
if serverID != "" </span><span class="cov4" title="2">{
server, err := h.remoteServerService.GetByUUID(serverID)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "Remote server not found"})
return
}</span>
// Construct Docker host string
// Assuming TCP for now as that's what RemoteServer supports (Host/Port)
// TODO: Support SSH if/when RemoteServer supports it
<span class="cov1" title="1">host = fmt.Sprintf("tcp://%s:%d", server.Host, server.Port)</span>
}
<span class="cov6" title="3">containers, err := h.dockerService.ListContainers(c.Request.Context(), host)
if err != nil </span><span class="cov4" title="2">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers: " + err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, containers)</span>
}
</pre>
<pre class="file" id="file7" style="display: none">package handlers
import (
"fmt"
"net/http"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type DomainHandler struct {
DB *gorm.DB
notificationService *services.NotificationService
}
func NewDomainHandler(db *gorm.DB, ns *services.NotificationService) *DomainHandler <span class="cov10" title="6">{
return &amp;DomainHandler{
DB: db,
notificationService: ns,
}
}</span>
func (h *DomainHandler) List(c *gin.Context) <span class="cov6" title="3">{
var domains []models.Domain
if err := h.DB.Order("name asc").Find(&amp;domains).Error; err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch domains"})
return
}</span>
<span class="cov6" title="3">c.JSON(http.StatusOK, domains)</span>
}
func (h *DomainHandler) Create(c *gin.Context) <span class="cov10" title="6">{
var input struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&amp;input); err != nil </span><span class="cov4" title="2">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov7" title="4">domain := models.Domain{
Name: input.Name,
}
if err := h.DB.Create(&amp;domain).Error; err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create domain"})
return
}</span>
// Send Notification
<span class="cov6" title="3">if h.notificationService != nil </span><span class="cov6" title="3">{
h.notificationService.SendExternal(c.Request.Context(),
"domain",
"Domain Added",
fmt.Sprintf("Domain %s added", util.SanitizeForLog(domain.Name)),
map[string]interface{}{
"Name": util.SanitizeForLog(domain.Name),
"Action": "created",
},
)
}</span>
<span class="cov6" title="3">c.JSON(http.StatusCreated, domain)</span>
}
func (h *DomainHandler) Delete(c *gin.Context) <span class="cov4" title="2">{
id := c.Param("id")
var domain models.Domain
if err := h.DB.Where("uuid = ?", id).First(&amp;domain).Error; err == nil </span><span class="cov1" title="1">{
// Send Notification before delete (or after if we keep the name)
if h.notificationService != nil </span><span class="cov1" title="1">{
h.notificationService.SendExternal(c.Request.Context(),
"domain",
"Domain Deleted",
fmt.Sprintf("Domain %s deleted", util.SanitizeForLog(domain.Name)),
map[string]interface{}{
"Name": util.SanitizeForLog(domain.Name),
"Action": "deleted",
},
)
}</span>
}
<span class="cov4" title="2">if err := h.DB.Where("uuid = ?", id).Delete(&amp;models.Domain{}).Error; err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete domain"})
return
}</span>
<span class="cov4" title="2">c.JSON(http.StatusOK, gin.H{"message": "Domain deleted"})</span>
}
</pre>
<pre class="file" id="file8" style="display: none">package handlers
import (
"net/http"
"os"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
// FeatureFlagsHandler exposes simple DB-backed feature flags with env fallback.
type FeatureFlagsHandler struct {
DB *gorm.DB
}
func NewFeatureFlagsHandler(db *gorm.DB) *FeatureFlagsHandler <span class="cov6" title="11">{
return &amp;FeatureFlagsHandler{DB: db}
}</span>
// defaultFlags lists the canonical feature flags we expose.
var defaultFlags = []string{
"feature.global.enabled",
"feature.cerberus.enabled",
"feature.uptime.enabled",
"feature.notifications.enabled",
"feature.docker.enabled",
}
// GetFlags returns a map of feature flag -&gt; bool. DB setting takes precedence
// and falls back to environment variables if present.
func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) <span class="cov6" title="9">{
result := make(map[string]bool)
for _, key := range defaultFlags </span><span class="cov10" title="45">{
// Try DB
var s models.Setting
if err := h.DB.Where("key = ?", key).First(&amp;s).Error; err == nil </span><span class="cov6" title="9">{
v := strings.ToLower(strings.TrimSpace(s.Value))
b := v == "1" || v == "true" || v == "yes"
result[key] = b
continue</span>
}
// Fallback to env vars. Try FEATURE_... and also stripped service name e.g. CERBERUS_ENABLED
<span class="cov9" title="36">envKey := strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
if ev, ok := os.LookupEnv(envKey); ok </span><span class="cov3" title="3">{
if bv, err := strconv.ParseBool(ev); err == nil </span><span class="cov3" title="3">{
result[key] = bv
continue</span>
}
// accept 1/0
<span class="cov0" title="0">result[key] = ev == "1"
continue</span>
}
// Try shorter variant after removing leading "feature."
<span class="cov9" title="33">if strings.HasPrefix(key, "feature.") </span><span class="cov9" title="33">{
short := strings.ToUpper(strings.ReplaceAll(strings.TrimPrefix(key, "feature."), ".", "_"))
if ev, ok := os.LookupEnv(short); ok </span><span class="cov2" title="2">{
if bv, err := strconv.ParseBool(ev); err == nil </span><span class="cov2" title="2">{
result[key] = bv
continue</span>
}
<span class="cov0" title="0">result[key] = ev == "1"
continue</span>
}
}
// Default false
<span class="cov9" title="31">result[key] = false</span>
}
<span class="cov6" title="9">c.JSON(http.StatusOK, result)</span>
}
// UpdateFlags accepts a JSON object map[string]bool and upserts settings.
func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) <span class="cov4" title="4">{
var payload map[string]bool
if err := c.ShouldBindJSON(&amp;payload); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov3" title="3">for k, v := range payload </span><span class="cov5" title="6">{
// Only allow keys in the default list to avoid arbitrary settings
allowed := false
for _, ak := range defaultFlags </span><span class="cov7" title="13">{
if ak == k </span><span class="cov4" title="5">{
allowed = true
break</span>
}
}
<span class="cov5" title="6">if !allowed </span><span class="cov1" title="1">{
continue</span>
}
<span class="cov4" title="5">s := models.Setting{Key: k, Value: strconv.FormatBool(v), Type: "bool", Category: "feature"}
if err := h.DB.Where(models.Setting{Key: k}).Assign(s).FirstOrCreate(&amp;s).Error; err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save setting"})
return
}</span>
}
<span class="cov3" title="3">c.JSON(http.StatusOK, gin.H{"status": "ok"})</span>
}
</pre>
<pre class="file" id="file9" style="display: none">package handlers
import (
"net"
"net/http"
"github.com/Wikid82/charon/backend/internal/version"
"github.com/gin-gonic/gin"
)
// getLocalIP returns the non-loopback local IP of the host
func getLocalIP() string <span class="cov5" title="2">{
addrs, err := net.InterfaceAddrs()
if err != nil </span><span class="cov0" title="0">{
return ""
}</span>
<span class="cov5" title="2">for _, address := range addrs </span><span class="cov10" title="4">{
// check the address type and if it is not a loopback then return it
if ipnet, ok := address.(*net.IPNet); ok &amp;&amp; !ipnet.IP.IsLoopback() </span><span class="cov5" title="2">{
if ipnet.IP.To4() != nil </span><span class="cov5" title="2">{
return ipnet.IP.String()
}</span>
}
}
<span class="cov0" title="0">return ""</span>
}
// HealthHandler responds with basic service metadata for uptime checks.
func HealthHandler(c *gin.Context) <span class="cov5" title="2">{
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": version.Name,
"version": version.Version,
"git_commit": version.GitCommit,
"build_time": version.BuildTime,
"internal_ip": getLocalIP(),
})
}</span>
</pre>
<pre class="file" id="file10" style="display: none">package handlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"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/services"
"github.com/Wikid82/charon/backend/internal/util"
)
// ImportHandler handles Caddyfile import operations.
type ImportHandler struct {
db *gorm.DB
proxyHostSvc *services.ProxyHostService
importerservice *caddy.Importer
importDir string
mountPath string
}
// NewImportHandler creates a new import handler.
func NewImportHandler(db *gorm.DB, caddyBinary, importDir, mountPath string) *ImportHandler <span class="cov8" title="22">{
return &amp;ImportHandler{
db: db,
proxyHostSvc: services.NewProxyHostService(db),
importerservice: caddy.NewImporter(caddyBinary),
importDir: importDir,
mountPath: mountPath,
}
}</span>
// RegisterRoutes registers import-related routes.
func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) <span class="cov1" title="1">{
router.GET("/import/status", h.GetStatus)
router.GET("/import/preview", h.GetPreview)
router.POST("/import/upload", h.Upload)
router.POST("/import/upload-multi", h.UploadMulti)
router.POST("/import/detect-imports", h.DetectImports)
router.POST("/import/commit", h.Commit)
router.DELETE("/import/cancel", h.Cancel)
}</span>
// GetStatus returns current import session status.
func (h *ImportHandler) GetStatus(c *gin.Context) <span class="cov4" title="4">{
var session models.ImportSession
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
Order("created_at DESC").
First(&amp;session).Error
if err == gorm.ErrRecordNotFound </span><span class="cov3" title="3">{
// No pending/reviewing session, check if there's a mounted Caddyfile available for transient preview
if h.mountPath != "" </span><span class="cov1" title="1">{
if fileInfo, err := os.Stat(h.mountPath); err == nil </span><span class="cov1" title="1">{
// Check if this mount has already been committed recently
var committedSession models.ImportSession
err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
Order("committed_at DESC").
First(&amp;committedSession).Error
// Allow re-import if:
// 1. Never committed before (err == gorm.ErrRecordNotFound), OR
// 2. File was modified after last commit
allowImport := err == gorm.ErrRecordNotFound
if !allowImport &amp;&amp; committedSession.CommittedAt != nil </span><span class="cov0" title="0">{
fileMod := fileInfo.ModTime()
commitTime := *committedSession.CommittedAt
allowImport = fileMod.After(commitTime)
}</span>
<span class="cov1" title="1">if allowImport </span><span class="cov1" title="1">{
// Mount file is available for import
c.JSON(http.StatusOK, gin.H{
"has_pending": true,
"session": gin.H{
"id": "transient",
"state": "transient",
"source_file": h.mountPath,
},
})
return
}</span>
// Mount file was already committed and hasn't been modified, don't offer it again
}
}
<span class="cov2" title="2">c.JSON(http.StatusOK, gin.H{"has_pending": false})
return</span>
}
<span class="cov1" title="1">if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{
"has_pending": true,
"session": gin.H{
"id": session.UUID,
"state": session.Status,
"created_at": session.CreatedAt,
"updated_at": session.UpdatedAt,
},
})</span>
}
// GetPreview returns parsed hosts and conflicts for review.
func (h *ImportHandler) GetPreview(c *gin.Context) <span class="cov4" title="5">{
var session models.ImportSession
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
Order("created_at DESC").
First(&amp;session).Error
if err == nil </span><span class="cov3" title="3">{
// DB session found
var result caddy.ImportResult
if err := json.Unmarshal([]byte(session.ParsedData), &amp;result); err == nil </span><span class="cov3" title="3">{
// Update status to reviewing
session.Status = "reviewing"
h.db.Save(&amp;session)
// Read original Caddyfile content if available
var caddyfileContent string
if session.SourceFile != "" </span><span class="cov2" title="2">{
if content, err := os.ReadFile(session.SourceFile); err == nil </span><span class="cov1" title="1">{
caddyfileContent = string(content)
}</span> else<span class="cov1" title="1"> {
backupPath := filepath.Join(h.importDir, "backups", filepath.Base(session.SourceFile))
if content, err := os.ReadFile(backupPath); err == nil </span><span class="cov1" title="1">{
caddyfileContent = string(content)
}</span>
}
}
<span class="cov3" title="3">c.JSON(http.StatusOK, gin.H{
"session": gin.H{
"id": session.UUID,
"state": session.Status,
"created_at": session.CreatedAt,
"updated_at": session.UpdatedAt,
"source_file": session.SourceFile,
},
"preview": result,
"caddyfile_content": caddyfileContent,
})
return</span>
}
}
// No DB session found or failed to parse session. Try transient preview from mountPath.
<span class="cov2" title="2">if h.mountPath != "" </span><span class="cov1" title="1">{
if fileInfo, err := os.Stat(h.mountPath); err == nil </span><span class="cov1" title="1">{
// Check if this mount has already been committed recently
var committedSession models.ImportSession
err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
Order("committed_at DESC").
First(&amp;committedSession).Error
// Allow preview if:
// 1. Never committed before (err == gorm.ErrRecordNotFound), OR
// 2. File was modified after last commit
allowPreview := err == gorm.ErrRecordNotFound
if !allowPreview &amp;&amp; committedSession.CommittedAt != nil </span><span class="cov0" title="0">{
allowPreview = fileInfo.ModTime().After(*committedSession.CommittedAt)
}</span>
<span class="cov1" title="1">if !allowPreview </span><span class="cov0" title="0">{
// Mount file was already committed and hasn't been modified, don't offer preview again
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
return
}</span>
// Parse mounted Caddyfile transiently
<span class="cov1" title="1">transient, err := h.importerservice.ImportFile(h.mountPath)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
return
}</span>
// Build a transient session id (not persisted)
<span class="cov1" title="1">sid := uuid.NewString()
var caddyfileContent string
if content, err := os.ReadFile(h.mountPath); err == nil </span><span class="cov1" title="1">{
caddyfileContent = string(content)
}</span>
// Check for conflicts with existing hosts and build conflict details
<span class="cov1" title="1">existingHosts, _ := h.proxyHostSvc.List()
existingDomainsMap := make(map[string]models.ProxyHost)
for _, eh := range existingHosts </span><span class="cov0" title="0">{
existingDomainsMap[eh.DomainNames] = eh
}</span>
<span class="cov1" title="1">conflictDetails := make(map[string]gin.H)
for _, ph := range transient.Hosts </span><span class="cov1" title="1">{
if existing, found := existingDomainsMap[ph.DomainNames]; found </span><span class="cov0" title="0">{
transient.Conflicts = append(transient.Conflicts, ph.DomainNames)
conflictDetails[ph.DomainNames] = gin.H{
"existing": gin.H{
"forward_scheme": existing.ForwardScheme,
"forward_host": existing.ForwardHost,
"forward_port": existing.ForwardPort,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}</span>
}
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": h.mountPath},
"preview": transient,
"caddyfile_content": caddyfileContent,
"conflict_details": conflictDetails,
})
return</span>
}
}
<span class="cov1" title="1">c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})</span>
}
// Upload handles manual Caddyfile upload or paste.
func (h *ImportHandler) Upload(c *gin.Context) <span class="cov5" title="7">{
var req struct {
Content string `json:"content" binding:"required"`
Filename string `json:"filename"`
}
// Capture raw request for better diagnostics in tests
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov1" title="1">{
// Try to include raw body preview when binding fails
entry := middleware.GetRequestLogger(c)
if raw, _ := c.GetRawData(); len(raw) &gt; 0 </span><span class="cov0" title="0">{
entry.WithError(err).WithField("raw_body_preview", util.SanitizeForLog(string(raw))).Error("Import Upload: failed to bind JSON")
}</span> else<span class="cov1" title="1"> {
entry.WithError(err).Error("Import Upload: failed to bind JSON")
}</span>
<span class="cov1" title="1">c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return</span>
}
<span class="cov5" title="6">middleware.GetRequestLogger(c).WithField("filename", util.SanitizeForLog(filepath.Base(req.Filename))).WithField("content_len", len(req.Content)).Info("Import Upload: received upload")
// Save upload to import/uploads/&lt;uuid&gt;.caddyfile and return transient preview (do not persist yet)
sid := uuid.NewString()
uploadsDir, err := safeJoin(h.importDir, "uploads")
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid import directory"})
return
}</span>
<span class="cov5" title="6">if err := os.MkdirAll(uploadsDir, 0755); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
return
}</span>
<span class="cov5" title="6">tempPath, err := safeJoin(uploadsDir, fmt.Sprintf("%s.caddyfile", sid))
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid temp path"})
return
}</span>
<span class="cov5" title="6">if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil </span><span class="cov0" title="0">{
middleware.GetRequestLogger(c).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithError(err).Error("Import Upload: failed to write temp file")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
return
}</span>
// Parse uploaded file transiently
<span class="cov5" title="6">result, err := h.importerservice.ImportFile(tempPath)
if err != nil </span><span class="cov2" title="2">{
// Read a small preview of the uploaded file for diagnostics
preview := ""
if b, rerr := os.ReadFile(tempPath); rerr == nil </span><span class="cov2" title="2">{
if len(b) &gt; 200 </span><span class="cov0" title="0">{
preview = string(b[:200])
}</span> else<span class="cov2" title="2"> {
preview = string(b)
}</span>
}
<span class="cov2" title="2">middleware.GetRequestLogger(c).WithError(err).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithField("content_preview", util.SanitizeForLog(preview)).Error("Import Upload: import failed")
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
return</span>
}
// If no hosts were parsed, provide a clearer error when import directives exist
<span class="cov4" title="4">if len(result.Hosts) == 0 </span><span class="cov1" title="1">{
imports := detectImportDirectives(req.Content)
if len(imports) &gt; 0 </span><span class="cov0" title="0">{
sanitizedImports := make([]string, 0, len(imports))
for _, imp := range imports </span><span class="cov0" title="0">{
sanitizedImports = append(sanitizedImports, util.SanitizeForLog(filepath.Base(imp)))
}</span>
<span class="cov0" title="0">middleware.GetRequestLogger(c).WithField("imports", sanitizedImports).Warn("Import Upload: no hosts parsed but imports detected")</span>
} else<span class="cov1" title="1"> {
middleware.GetRequestLogger(c).WithField("content_len", len(req.Content)).Warn("Import Upload: no hosts parsed and no imports detected")
}</span>
<span class="cov1" title="1">if len(imports) &gt; 0 </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile; imports detected; please upload the referenced site files using the multi-file import flow", "imports": imports})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile"})
return</span>
}
// Check for conflicts with existing hosts and build conflict details
<span class="cov3" title="3">existingHosts, _ := h.proxyHostSvc.List()
existingDomainsMap := make(map[string]models.ProxyHost)
for _, eh := range existingHosts </span><span class="cov1" title="1">{
existingDomainsMap[eh.DomainNames] = eh
}</span>
<span class="cov3" title="3">conflictDetails := make(map[string]gin.H)
for _, ph := range result.Hosts </span><span class="cov3" title="3">{
if existing, found := existingDomainsMap[ph.DomainNames]; found </span><span class="cov1" title="1">{
result.Conflicts = append(result.Conflicts, ph.DomainNames)
conflictDetails[ph.DomainNames] = gin.H{
"existing": gin.H{
"forward_scheme": existing.ForwardScheme,
"forward_host": existing.ForwardHost,
"forward_port": existing.ForwardPort,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}</span>
}
<span class="cov3" title="3">c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": tempPath},
"conflict_details": conflictDetails,
"preview": result,
})</span>
}
// DetectImports analyzes Caddyfile content and returns detected import directives.
func (h *ImportHandler) DetectImports(c *gin.Context) <span class="cov4" title="5">{
var req struct {
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov1" title="1">{
entry := middleware.GetRequestLogger(c)
if raw, _ := c.GetRawData(); len(raw) &gt; 0 </span><span class="cov0" title="0">{
entry.WithError(err).WithField("raw_body_preview", util.SanitizeForLog(string(raw))).Error("Import UploadMulti: failed to bind JSON")
}</span> else<span class="cov1" title="1"> {
entry.WithError(err).Error("Import UploadMulti: failed to bind JSON")
}</span>
<span class="cov1" title="1">c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return</span>
}
<span class="cov4" title="4">imports := detectImportDirectives(req.Content)
c.JSON(http.StatusOK, gin.H{
"has_imports": len(imports) &gt; 0,
"imports": imports,
})</span>
}
// UploadMulti handles upload of main Caddyfile + multiple site files.
func (h *ImportHandler) UploadMulti(c *gin.Context) <span class="cov4" title="5">{
var req struct {
Files []struct {
Filename string `json:"filename" binding:"required"`
Content string `json:"content" binding:"required"`
} `json:"files" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
// Validate: at least one file must be named "Caddyfile" or have no path separator
<span class="cov4" title="5">hasCaddyfile := false
for _, f := range req.Files </span><span class="cov4" title="5">{
if f.Filename == "Caddyfile" || !strings.Contains(f.Filename, "/") </span><span class="cov4" title="4">{
hasCaddyfile = true
break</span>
}
}
<span class="cov4" title="5">if !hasCaddyfile </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "must include a main Caddyfile"})
return
}</span>
// Create session directory
<span class="cov4" title="4">sid := uuid.NewString()
sessionDir, err := safeJoin(h.importDir, filepath.Join("uploads", sid))
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid session directory"})
return
}</span>
<span class="cov4" title="4">if err := os.MkdirAll(sessionDir, 0755); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session directory"})
return
}</span>
// Write all files
<span class="cov4" title="4">mainCaddyfile := ""
for _, f := range req.Files </span><span class="cov6" title="8">{
if strings.TrimSpace(f.Content) == "" </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file '%s' is empty", f.Filename)})
return
}</span>
// Clean filename and create subdirectories if needed
<span class="cov5" title="7">cleanName := filepath.Clean(f.Filename)
targetPath, err := safeJoin(sessionDir, cleanName)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid filename: %s", f.Filename)})
return
}</span>
// Create parent directory if file is in a subdirectory
<span class="cov5" title="6">if dir := filepath.Dir(targetPath); dir != sessionDir </span><span class="cov2" title="2">{
if err := os.MkdirAll(dir, 0755); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create directory for %s", f.Filename)})
return
}</span>
}
<span class="cov5" title="6">if err := os.WriteFile(targetPath, []byte(f.Content), 0644); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s", f.Filename)})
return
}</span>
// Track main Caddyfile
<span class="cov5" title="6">if cleanName == "Caddyfile" || !strings.Contains(cleanName, "/") </span><span class="cov4" title="4">{
mainCaddyfile = targetPath
}</span>
}
// Parse the main Caddyfile (which will automatically resolve imports)
<span class="cov2" title="2">result, err := h.importerservice.ImportFile(mainCaddyfile)
if err != nil </span><span class="cov0" title="0">{
// Provide diagnostics
preview := ""
if b, rerr := os.ReadFile(mainCaddyfile); rerr == nil </span><span class="cov0" title="0">{
if len(b) &gt; 200 </span><span class="cov0" title="0">{
preview = string(b[:200])
}</span> else<span class="cov0" title="0"> {
preview = string(b)
}</span>
}
<span class="cov0" title="0">middleware.GetRequestLogger(c).WithError(err).WithField("mainCaddyfile", util.SanitizeForLog(filepath.Base(mainCaddyfile))).WithField("preview", util.SanitizeForLog(preview)).Error("Import UploadMulti: import failed")
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
return</span>
}
// If parsing succeeded but no hosts were found, and imports were present in the main file,
// inform the caller to upload the site files.
<span class="cov2" title="2">if len(result.Hosts) == 0 </span><span class="cov0" title="0">{
mainContentBytes, _ := os.ReadFile(mainCaddyfile)
imports := detectImportDirectives(string(mainContentBytes))
if len(imports) &gt; 0 </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "no sites parsed from main Caddyfile; import directives detected; please include site files in upload", "imports": imports})
return
}</span>
<span class="cov0" title="0">c.JSON(http.StatusBadRequest, gin.H{"error": "no sites parsed from main Caddyfile"})
return</span>
}
// Check for conflicts
<span class="cov2" title="2">existingHosts, _ := h.proxyHostSvc.List()
existingDomains := make(map[string]bool)
for _, eh := range existingHosts </span><span class="cov0" title="0">{
existingDomains[eh.DomainNames] = true
}</span>
<span class="cov2" title="2">for _, ph := range result.Hosts </span><span class="cov2" title="2">{
if existingDomains[ph.DomainNames] </span><span class="cov0" title="0">{
result.Conflicts = append(result.Conflicts, ph.DomainNames)
}</span>
}
<span class="cov2" title="2">c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": mainCaddyfile},
"preview": result,
})</span>
}
// detectImportDirectives scans Caddyfile content for import directives.
func detectImportDirectives(content string) []string <span class="cov4" title="5">{
imports := []string{}
lines := strings.Split(content, "\n")
for _, line := range lines </span><span class="cov6" title="9">{
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "import ") </span><span class="cov4" title="4">{
path := strings.TrimSpace(strings.TrimPrefix(trimmed, "import"))
// Remove any trailing comments
if idx := strings.Index(path, "#"); idx != -1 </span><span class="cov1" title="1">{
path = strings.TrimSpace(path[:idx])
}</span>
<span class="cov4" title="4">imports = append(imports, path)</span>
}
}
<span class="cov4" title="5">return imports</span>
}
// safeJoin joins a user-supplied path to a base directory and ensures
// the resulting path is contained within the base directory.
func safeJoin(baseDir, userPath string) (string, error) <span class="cov10" title="38">{
clean := filepath.Clean(userPath)
if clean == "" || clean == "." </span><span class="cov2" title="2">{
return "", fmt.Errorf("empty path not allowed")
}</span>
<span class="cov9" title="36">if filepath.IsAbs(clean) </span><span class="cov1" title="1">{
return "", fmt.Errorf("absolute paths not allowed")
}</span>
// Prevent attempts like ".." at start
<span class="cov9" title="35">if strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || clean == ".." </span><span class="cov3" title="3">{
return "", fmt.Errorf("path traversal detected")
}</span>
<span class="cov9" title="32">target := filepath.Join(baseDir, clean)
rel, err := filepath.Rel(baseDir, target)
if err != nil </span><span class="cov0" title="0">{
return "", fmt.Errorf("invalid path")
}</span>
<span class="cov9" title="32">if strings.HasPrefix(rel, "..") </span><span class="cov0" title="0">{
return "", fmt.Errorf("path traversal detected")
}</span>
// Normalize to use base's separators
<span class="cov9" title="32">target = path.Clean(target)
return target, nil</span>
}
// isSafePathUnderBase reports whether userPath, when cleaned and joined
// to baseDir, stays within baseDir. Used by tests.
func isSafePathUnderBase(baseDir, userPath string) bool <span class="cov6" title="8">{
_, err := safeJoin(baseDir, userPath)
return err == nil
}</span>
// Commit finalizes the import with user's conflict resolutions.
func (h *ImportHandler) Commit(c *gin.Context) <span class="cov6" title="8">{
var req struct {
SessionUUID string `json:"session_uuid" binding:"required"`
Resolutions map[string]string `json:"resolutions"` // domain -&gt; action (keep/skip, overwrite, rename)
Names map[string]string `json:"names"` // domain -&gt; custom name
}
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov2" title="2">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
// Try to find a DB-backed session first
<span class="cov5" title="6">var session models.ImportSession
// Basic sanitize of session id to prevent path separators
sid := filepath.Base(req.SessionUUID)
if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"})
return
}</span>
<span class="cov5" title="6">var result *caddy.ImportResult
if err := h.db.Where("uuid = ? AND status = ?", sid, "reviewing").First(&amp;session).Error; err == nil </span><span class="cov2" title="2">{
// DB session found
if err := json.Unmarshal([]byte(session.ParsedData), &amp;result); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
return
}</span>
} else<span class="cov4" title="4"> {
// No DB session: check for uploaded temp file
var parseErr error
uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid)))
if err == nil </span><span class="cov4" title="4">{
if _, err := os.Stat(uploadsPath); err == nil </span><span class="cov1" title="1">{
r, err := h.importerservice.ImportFile(uploadsPath)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse uploaded file"})
return
}</span>
<span class="cov1" title="1">result = r
// We'll create a committed DB session after applying
session = models.ImportSession{UUID: sid, SourceFile: uploadsPath}</span>
}
}
// If not found yet, check mounted Caddyfile
<span class="cov4" title="4">if result == nil &amp;&amp; h.mountPath != "" </span><span class="cov1" title="1">{
if _, err := os.Stat(h.mountPath); err == nil </span><span class="cov1" title="1">{
r, err := h.importerservice.ImportFile(h.mountPath)
if err != nil </span><span class="cov0" title="0">{
parseErr = err
}</span> else<span class="cov1" title="1"> {
result = r
session = models.ImportSession{UUID: sid, SourceFile: h.mountPath}
}</span>
}
}
// If still not parsed, return not found or error
<span class="cov4" title="4">if result == nil </span><span class="cov2" title="2">{
if parseErr != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
return
}</span>
<span class="cov2" title="2">c.JSON(http.StatusNotFound, gin.H{"error": "session not found or file missing"})
return</span>
}
}
// Convert parsed hosts to ProxyHost models
<span class="cov3" title="3">proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
middleware.GetRequestLogger(c).WithField("parsed_hosts", len(result.Hosts)).WithField("proxy_hosts", len(proxyHosts)).Info("Import Commit: Parsed and converted hosts")
created := 0
updated := 0
skipped := 0
errors := []string{}
// Get existing hosts to check for overwrites
existingHosts, _ := h.proxyHostSvc.List()
existingMap := make(map[string]*models.ProxyHost)
for i := range existingHosts </span><span class="cov0" title="0">{
existingMap[existingHosts[i].DomainNames] = &amp;existingHosts[i]
}</span>
<span class="cov3" title="3">for _, host := range proxyHosts </span><span class="cov3" title="3">{
action := req.Resolutions[host.DomainNames]
// Apply custom name from user input
if customName, ok := req.Names[host.DomainNames]; ok &amp;&amp; customName != "" </span><span class="cov0" title="0">{
host.Name = customName
}</span>
// "keep" means keep existing (don't import), same as "skip"
<span class="cov3" title="3">if action == "skip" || action == "keep" </span><span class="cov0" title="0">{
skipped++
continue</span>
}
<span class="cov3" title="3">if action == "rename" </span><span class="cov0" title="0">{
host.DomainNames += "-imported"
}</span>
// Handle overwrite: preserve existing ID, UUID, and certificate
<span class="cov3" title="3">if action == "overwrite" </span><span class="cov0" title="0">{
if existing, found := existingMap[host.DomainNames]; found </span><span class="cov0" title="0">{
host.ID = existing.ID
host.UUID = existing.UUID
host.CertificateID = existing.CertificateID // Preserve certificate association
host.CreatedAt = existing.CreatedAt
if err := h.proxyHostSvc.Update(&amp;host); err != nil </span><span class="cov0" title="0">{
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
errors = append(errors, errMsg)
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).WithField("error", sanitizeForLog(errMsg)).Error("Import Commit Error (update)")
}</span> else<span class="cov0" title="0"> {
updated++
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).Info("Import Commit Success: Updated host")
}</span>
<span class="cov0" title="0">continue</span>
}
// If "overwrite" but doesn't exist, fall through to create
}
// Create new host
<span class="cov3" title="3">host.UUID = uuid.NewString()
if err := h.proxyHostSvc.Create(&amp;host); err != nil </span><span class="cov0" title="0">{
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
errors = append(errors, errMsg)
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).WithField("error", util.SanitizeForLog(errMsg)).Error("Import Commit Error")
}</span> else<span class="cov3" title="3"> {
created++
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).Info("Import Commit Success: Created host")
}</span>
}
// Persist an import session record now that user confirmed
<span class="cov3" title="3">now := time.Now()
session.Status = "committed"
session.CommittedAt = &amp;now
session.UserResolutions = string(mustMarshal(req.Resolutions))
// If ParsedData/ConflictReport not set, fill from result
if session.ParsedData == "" </span><span class="cov2" title="2">{
session.ParsedData = string(mustMarshal(result))
}</span>
<span class="cov3" title="3">if session.ConflictReport == "" </span><span class="cov3" title="3">{
session.ConflictReport = string(mustMarshal(result.Conflicts))
}</span>
<span class="cov3" title="3">if err := h.db.Save(&amp;session).Error; err != nil </span><span class="cov0" title="0">{
middleware.GetRequestLogger(c).WithError(err).Warn("Warning: failed to save import session")
}</span>
<span class="cov3" title="3">c.JSON(http.StatusOK, gin.H{
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors,
})</span>
}
// Cancel discards a pending import session.
func (h *ImportHandler) Cancel(c *gin.Context) <span class="cov4" title="4">{
sessionUUID := c.Query("session_uuid")
if sessionUUID == "" </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
return
}</span>
<span class="cov4" title="4">sid := filepath.Base(sessionUUID)
if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"})
return
}</span>
<span class="cov4" title="4">var session models.ImportSession
if err := h.db.Where("uuid = ?", sid).First(&amp;session).Error; err == nil </span><span class="cov1" title="1">{
session.Status = "rejected"
h.db.Save(&amp;session)
c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
return
}</span>
// If no DB session, check for uploaded temp file and delete it
<span class="cov3" title="3">uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid)))
if err == nil </span><span class="cov3" title="3">{
if _, err := os.Stat(uploadsPath); err == nil </span><span class="cov1" title="1">{
os.Remove(uploadsPath)
c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"})
return
}</span>
}
// If neither exists, return not found
<span class="cov2" title="2">c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})</span>
}
// CheckMountedImport checks for mounted Caddyfile on startup.
func CheckMountedImport(db *gorm.DB, mountPath, caddyBinary, importDir string) error <span class="cov3" title="3">{
if _, err := os.Stat(mountPath); os.IsNotExist(err) </span><span class="cov1" title="1">{
// If mount is gone, remove any pending/reviewing sessions created previously for this mount
db.Where("source_file = ? AND status IN ?", mountPath, []string{"pending", "reviewing"}).Delete(&amp;models.ImportSession{})
return nil // No mounted file, nothing to import
}</span>
// Check if already processed (includes committed to avoid re-imports)
<span class="cov2" title="2">var count int64
db.Model(&amp;models.ImportSession{}).Where("source_file = ? AND status IN ?",
mountPath, []string{"pending", "reviewing", "committed"}).Count(&amp;count)
if count &gt; 0 </span><span class="cov0" title="0">{
return nil // Already processed
}</span>
// Do not create a DB session automatically for mounted imports; preview will be transient.
<span class="cov2" title="2">return nil</span>
}
func mustMarshal(v interface{}) []byte <span class="cov6" title="8">{
b, _ := json.Marshal(v)
return b
}</span>
</pre>
<pre class="file" id="file11" style="display: none">package handlers
import (
"io"
"net/http"
"os"
"strconv"
"strings"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type LogsHandler struct {
service *services.LogService
}
func NewLogsHandler(service *services.LogService) *LogsHandler <span class="cov10" title="3">{
return &amp;LogsHandler{service: service}
}</span>
func (h *LogsHandler) List(c *gin.Context) <span class="cov6" title="2">{
logs, err := h.service.ListLogs()
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list logs"})
return
}</span>
<span class="cov6" title="2">c.JSON(http.StatusOK, logs)</span>
}
func (h *LogsHandler) Read(c *gin.Context) <span class="cov6" title="2">{
filename := c.Param("filename")
// Parse query parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
filter := models.LogFilter{
Search: c.Query("search"),
Host: c.Query("host"),
Status: c.Query("status"),
Level: c.Query("level"),
Limit: limit,
Offset: offset,
Sort: c.DefaultQuery("sort", "desc"),
}
logs, total, err := h.service.QueryLogs(filename, filter)
if err != nil </span><span class="cov1" title="1">{
if os.IsNotExist(err) </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
return
}</span>
<span class="cov0" title="0">c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read log"})
return</span>
}
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{
"filename": filename,
"logs": logs,
"total": total,
"limit": limit,
"offset": offset,
})</span>
}
func (h *LogsHandler) Download(c *gin.Context) <span class="cov10" title="3">{
filename := c.Param("filename")
path, err := h.service.GetLogPath(filename)
if err != nil </span><span class="cov6" title="2">{
if strings.Contains(err.Error(), "invalid filename") </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
return</span>
}
// Create a temporary file to serve a consistent snapshot
// This prevents Content-Length mismatches if the live log file grows during download
<span class="cov1" title="1">tmpFile, err := os.CreateTemp("", "charon-log-*.log")
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp file"})
return
}</span>
<span class="cov1" title="1">defer os.Remove(tmpFile.Name())
srcFile, err := os.Open(path)
if err != nil </span><span class="cov0" title="0">{
_ = tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open log file"})
return
}</span>
<span class="cov1" title="1">defer func() </span><span class="cov1" title="1">{ _ = srcFile.Close() }</span>()
<span class="cov1" title="1">if _, err := io.Copy(tmpFile, srcFile); err != nil </span><span class="cov0" title="0">{
_ = tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to copy log file"})
return
}</span>
<span class="cov1" title="1">_ = tmpFile.Close()
c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(tmpFile.Name())</span>
}
</pre>
<pre class="file" id="file12" style="display: none">package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type NotificationHandler struct {
service *services.NotificationService
}
func NewNotificationHandler(service *services.NotificationService) *NotificationHandler <span class="cov10" title="5">{
return &amp;NotificationHandler{service: service}
}</span>
func (h *NotificationHandler) List(c *gin.Context) <span class="cov4" title="2">{
unreadOnly := c.Query("unread") == "true"
notifications, err := h.service.List(unreadOnly)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"})
return
}</span>
<span class="cov4" title="2">c.JSON(http.StatusOK, notifications)</span>
}
func (h *NotificationHandler) MarkAsRead(c *gin.Context) <span class="cov4" title="2">{
id := c.Param("id")
if err := h.service.MarkAsRead(id); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"message": "Notification marked as read"})</span>
}
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) <span class="cov4" title="2">{
if err := h.service.MarkAllAsRead(); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark all notifications as read"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"message": "All notifications marked as read"})</span>
}
</pre>
<pre class="file" id="file13" style="display: none">package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type NotificationProviderHandler struct {
service *services.NotificationService
}
func NewNotificationProviderHandler(service *services.NotificationService) *NotificationProviderHandler <span class="cov10" title="6">{
return &amp;NotificationProviderHandler{service: service}
}</span>
func (h *NotificationProviderHandler) List(c *gin.Context) <span class="cov1" title="1">{
providers, err := h.service.ListProviders()
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, providers)</span>
}
func (h *NotificationProviderHandler) Create(c *gin.Context) <span class="cov7" title="4">{
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&amp;provider); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov6" title="3">if err := h.service.CreateProvider(&amp;provider); err != nil </span><span class="cov1" title="1">{
// If it's a validation error from template parsing, return 400
if strings.Contains(err.Error(), "invalid custom template") || strings.Contains(err.Error(), "rendered template") || strings.Contains(err.Error(), "failed to parse template") || strings.Contains(err.Error(), "failed to render template") </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov0" title="0">c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
return</span>
}
<span class="cov4" title="2">c.JSON(http.StatusCreated, provider)</span>
}
func (h *NotificationProviderHandler) Update(c *gin.Context) <span class="cov6" title="3">{
id := c.Param("id")
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&amp;provider); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov4" title="2">provider.ID = id
if err := h.service.UpdateProvider(&amp;provider); err != nil </span><span class="cov1" title="1">{
if strings.Contains(err.Error(), "invalid custom template") || strings.Contains(err.Error(), "rendered template") || strings.Contains(err.Error(), "failed to parse template") || strings.Contains(err.Error(), "failed to render template") </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov0" title="0">c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
return</span>
}
<span class="cov1" title="1">c.JSON(http.StatusOK, provider)</span>
}
func (h *NotificationProviderHandler) Delete(c *gin.Context) <span class="cov1" title="1">{
id := c.Param("id")
if err := h.service.DeleteProvider(id); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"message": "Provider deleted"})</span>
}
func (h *NotificationProviderHandler) Test(c *gin.Context) <span class="cov4" title="2">{
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&amp;provider); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">if err := h.service.TestProvider(provider); err != nil </span><span class="cov1" title="1">{
// Create internal notification for the failure
_, _ = h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed: %v", provider.Name, err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov0" title="0">c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})</span>
}
// Templates returns a list of built-in templates a provider can use.
func (h *NotificationProviderHandler) Templates(c *gin.Context) <span class="cov1" title="1">{
c.JSON(http.StatusOK, []gin.H{
{"id": "minimal", "name": "Minimal", "description": "Small JSON payload with title, message and time."},
{"id": "detailed", "name": "Detailed", "description": "Full JSON payload with host, services and all data."},
{"id": "custom", "name": "Custom", "description": "Use your own JSON template in the Config field."},
})
}</span>
// Preview renders the template for a provider and returns the resulting JSON object or an error.
func (h *NotificationProviderHandler) Preview(c *gin.Context) <span class="cov4" title="2">{
var raw map[string]interface{}
if err := c.ShouldBindJSON(&amp;raw); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov4" title="2">var provider models.NotificationProvider
// Marshal raw into provider to get proper types
if b, err := json.Marshal(raw); err == nil </span><span class="cov4" title="2">{
_ = json.Unmarshal(b, &amp;provider)
}</span>
<span class="cov4" title="2">var payload map[string]interface{}
if d, ok := raw["data"].(map[string]interface{}); ok </span><span class="cov0" title="0">{
payload = d
}</span>
<span class="cov4" title="2">if payload == nil </span><span class="cov4" title="2">{
payload = map[string]interface{}{}
}</span>
// Add some defaults for preview
<span class="cov4" title="2">if _, ok := payload["Title"]; !ok </span><span class="cov4" title="2">{
payload["Title"] = "Preview Title"
}</span>
<span class="cov4" title="2">if _, ok := payload["Message"]; !ok </span><span class="cov4" title="2">{
payload["Message"] = "Preview Message"
}</span>
<span class="cov4" title="2">payload["Time"] = time.Now().Format(time.RFC3339)
payload["EventType"] = "preview"
rendered, parsed, err := h.service.RenderTemplate(provider, payload)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})</span>
}
</pre>
<pre class="file" id="file14" style="display: none">package handlers
import (
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"net/http"
)
type NotificationTemplateHandler struct {
service *services.NotificationService
}
func NewNotificationTemplateHandler(s *services.NotificationService) *NotificationTemplateHandler <span class="cov10" title="4">{
return &amp;NotificationTemplateHandler{service: s}
}</span>
func (h *NotificationTemplateHandler) List(c *gin.Context) <span class="cov1" title="1">{
list, err := h.service.ListTemplates()
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list templates"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, list)</span>
}
func (h *NotificationTemplateHandler) Create(c *gin.Context) <span class="cov5" title="2">{
var t models.NotificationTemplate
if err := c.ShouldBindJSON(&amp;t); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">if err := h.service.CreateTemplate(&amp;t); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create template"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusCreated, t)</span>
}
func (h *NotificationTemplateHandler) Update(c *gin.Context) <span class="cov5" title="2">{
id := c.Param("id")
var t models.NotificationTemplate
if err := c.ShouldBindJSON(&amp;t); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">t.ID = id
if err := h.service.UpdateTemplate(&amp;t); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, t)</span>
}
func (h *NotificationTemplateHandler) Delete(c *gin.Context) <span class="cov1" title="1">{
id := c.Param("id")
if err := h.service.DeleteTemplate(id); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete template"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"message": "deleted"})</span>
}
// Preview allows rendering an arbitrary template (provided in request) or a stored template by id.
func (h *NotificationTemplateHandler) Preview(c *gin.Context) <span class="cov5" title="2">{
var raw map[string]interface{}
if err := c.ShouldBindJSON(&amp;raw); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">var tmplStr string
if id, ok := raw["template_id"].(string); ok &amp;&amp; id != "" </span><span class="cov1" title="1">{
t, err := h.service.GetTemplate(id)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "template not found"})
return
}</span>
<span class="cov1" title="1">tmplStr = t.Config</span>
} else<span class="cov0" title="0"> if s, ok := raw["template"].(string); ok </span><span class="cov0" title="0">{
tmplStr = s
}</span>
<span class="cov1" title="1">data := map[string]interface{}{}
if d, ok := raw["data"].(map[string]interface{}); ok </span><span class="cov1" title="1">{
data = d
}</span>
// Build a fake provider to leverage existing RenderTemplate logic
<span class="cov1" title="1">provider := models.NotificationProvider{Template: "custom", Config: tmplStr}
rendered, parsed, err := h.service.RenderTemplate(provider, data)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})</span>
}
</pre>
<pre class="file" id="file15" style="display: none">package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"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/services"
"github.com/Wikid82/charon/backend/internal/util"
)
// ProxyHostHandler handles CRUD operations for proxy hosts.
type ProxyHostHandler struct {
service *services.ProxyHostService
caddyManager *caddy.Manager
notificationService *services.NotificationService
uptimeService *services.UptimeService
}
// NewProxyHostHandler creates a new proxy host handler.
func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService, uptimeService *services.UptimeService) *ProxyHostHandler <span class="cov10" title="27">{
return &amp;ProxyHostHandler{
service: services.NewProxyHostService(db),
caddyManager: caddyManager,
notificationService: ns,
uptimeService: uptimeService,
}
}</span>
// RegisterRoutes registers proxy host routes.
func (h *ProxyHostHandler) RegisterRoutes(router *gin.RouterGroup) <span class="cov10" title="27">{
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)
}</span>
// List retrieves all proxy hosts.
func (h *ProxyHostHandler) List(c *gin.Context) <span class="cov4" title="3">{
hosts, err := h.service.List()
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov2" title="2">c.JSON(http.StatusOK, hosts)</span>
}
// Create creates a new proxy host.
func (h *ProxyHostHandler) Create(c *gin.Context) <span class="cov7" title="9">{
var host models.ProxyHost
if err := c.ShouldBindJSON(&amp;host); err != nil </span><span class="cov2" title="2">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
// Validate and normalize advanced config if present
<span class="cov6" title="7">if host.AdvancedConfig != "" </span><span class="cov4" title="3">{
var parsed interface{}
if err := json.Unmarshal([]byte(host.AdvancedConfig), &amp;parsed); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config JSON: " + err.Error()})
return
}</span>
<span class="cov2" title="2">parsed = caddy.NormalizeAdvancedConfig(parsed)
if norm, err := json.Marshal(parsed); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()})
return
}</span> else<span class="cov2" title="2"> {
host.AdvancedConfig = string(norm)
}</span>
}
<span class="cov5" title="6">host.UUID = uuid.NewString()
// Assign UUIDs to locations
for i := range host.Locations </span><span class="cov1" title="1">{
host.Locations[i].UUID = uuid.NewString()
}</span>
<span class="cov5" title="6">if err := h.service.Create(&amp;host); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov5" title="6">if h.caddyManager != nil </span><span class="cov2" title="2">{
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil </span><span class="cov1" title="1">{
// 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 </span><span class="cov0" title="0">{
idStr := strconv.FormatUint(uint64(host.ID), 10)
middleware.GetRequestLogger(c).WithField("host_id", idStr).WithError(deleteErr).Error("Critical: Failed to rollback host")
}</span>
<span class="cov1" title="1">c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return</span>
}
}
// Send Notification
<span class="cov5" title="5">if h.notificationService != nil </span><span class="cov5" title="5">{
h.notificationService.SendExternal(c.Request.Context(),
"proxy_host",
"Proxy Host Created",
fmt.Sprintf("Proxy Host %s (%s) created", util.SanitizeForLog(host.Name), util.SanitizeForLog(host.DomainNames)),
map[string]interface{}{
"Name": util.SanitizeForLog(host.Name),
"Domains": util.SanitizeForLog(host.DomainNames),
"Action": "created",
},
)
}</span>
<span class="cov5" title="5">c.JSON(http.StatusCreated, host)</span>
}
// Get retrieves a proxy host by UUID.
func (h *ProxyHostHandler) Get(c *gin.Context) <span class="cov4" title="4">{
uuid := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
if err != nil </span><span class="cov2" title="2">{
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}</span>
<span class="cov2" title="2">c.JSON(http.StatusOK, host)</span>
}
// Update updates an existing proxy host.
func (h *ProxyHostHandler) Update(c *gin.Context) <span class="cov8" title="16">{
uuidStr := c.Param("uuid")
host, err := h.service.GetByUUID(uuidStr)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}</span>
// Perform a partial update: only mutate fields present in payload
<span class="cov8" title="15">var payload map[string]interface{}
if err := c.ShouldBindJSON(&amp;payload); err != nil </span><span class="cov2" title="2">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
// Handle simple scalar fields by json tag names (snake_case)
<span class="cov8" title="13">if v, ok := payload["name"].(string); ok </span><span class="cov4" title="3">{
host.Name = v
}</span>
<span class="cov8" title="13">if v, ok := payload["domain_names"].(string); ok </span><span class="cov4" title="3">{
host.DomainNames = v
}</span>
<span class="cov8" title="13">if v, ok := payload["forward_scheme"].(string); ok </span><span class="cov4" title="3">{
host.ForwardScheme = v
}</span>
<span class="cov8" title="13">if v, ok := payload["forward_host"].(string); ok </span><span class="cov4" title="3">{
host.ForwardHost = v
}</span>
<span class="cov8" title="13">if v, ok := payload["forward_port"]; ok </span><span class="cov4" title="4">{
switch t := v.(type) </span>{
case float64:<span class="cov4" title="3">
host.ForwardPort = int(t)</span>
case int:<span class="cov0" title="0">
host.ForwardPort = t</span>
case string:<span class="cov1" title="1">
if p, err := strconv.Atoi(t); err == nil </span><span class="cov1" title="1">{
host.ForwardPort = p
}</span>
}
}
<span class="cov8" title="13">if v, ok := payload["ssl_forced"].(bool); ok </span><span class="cov1" title="1">{
host.SSLForced = v
}</span>
<span class="cov8" title="13">if v, ok := payload["http2_support"].(bool); ok </span><span class="cov1" title="1">{
host.HTTP2Support = v
}</span>
<span class="cov8" title="13">if v, ok := payload["hsts_enabled"].(bool); ok </span><span class="cov1" title="1">{
host.HSTSEnabled = v
}</span>
<span class="cov8" title="13">if v, ok := payload["hsts_subdomains"].(bool); ok </span><span class="cov1" title="1">{
host.HSTSSubdomains = v
}</span>
<span class="cov8" title="13">if v, ok := payload["block_exploits"].(bool); ok </span><span class="cov1" title="1">{
host.BlockExploits = v
}</span>
<span class="cov8" title="13">if v, ok := payload["websocket_support"].(bool); ok </span><span class="cov1" title="1">{
host.WebsocketSupport = v
}</span>
<span class="cov8" title="13">if v, ok := payload["application"].(string); ok </span><span class="cov1" title="1">{
host.Application = v
}</span>
<span class="cov8" title="13">if v, ok := payload["enabled"].(bool); ok </span><span class="cov5" title="5">{
host.Enabled = v
}</span>
// Nullable foreign keys
<span class="cov8" title="13">if v, ok := payload["certificate_id"]; ok </span><span class="cov2" title="2">{
if v == nil </span><span class="cov1" title="1">{
host.CertificateID = nil
}</span> else<span class="cov1" title="1"> {
switch t := v.(type) </span>{
case float64:<span class="cov1" title="1">
id := uint(t)
host.CertificateID = &amp;id</span>
case int:<span class="cov0" title="0">
id := uint(t)
host.CertificateID = &amp;id</span>
case string:<span class="cov0" title="0">
if n, err := strconv.ParseUint(t, 10, 32); err == nil </span><span class="cov0" title="0">{
id := uint(n)
host.CertificateID = &amp;id
}</span>
}
}
}
<span class="cov8" title="13">if v, ok := payload["access_list_id"]; ok </span><span class="cov0" title="0">{
if v == nil </span><span class="cov0" title="0">{
host.AccessListID = nil
}</span> else<span class="cov0" title="0"> {
switch t := v.(type) </span>{
case float64:<span class="cov0" title="0">
id := uint(t)
host.AccessListID = &amp;id</span>
case int:<span class="cov0" title="0">
id := uint(t)
host.AccessListID = &amp;id</span>
case string:<span class="cov0" title="0">
if n, err := strconv.ParseUint(t, 10, 32); err == nil </span><span class="cov0" title="0">{
id := uint(n)
host.AccessListID = &amp;id
}</span>
}
}
}
// Locations: replace only if provided
<span class="cov8" title="13">if v, ok := payload["locations"].([]interface{}); ok </span><span class="cov2" title="2">{
// Rebind to []models.Location
b, _ := json.Marshal(v)
var locs []models.Location
if err := json.Unmarshal(b, &amp;locs); err == nil </span><span class="cov1" title="1">{
// Ensure UUIDs exist for any new location entries
for i := range locs </span><span class="cov2" title="2">{
if locs[i].UUID == "" </span><span class="cov2" title="2">{
locs[i].UUID = uuid.New().String()
}</span>
}
<span class="cov1" title="1">host.Locations = locs</span>
} else<span class="cov1" title="1"> {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid locations payload"})
return
}</span>
}
// Advanced config: normalize if provided and changed
<span class="cov7" title="12">if v, ok := payload["advanced_config"].(string); ok </span><span class="cov4" title="3">{
if v != "" &amp;&amp; v != host.AdvancedConfig </span><span class="cov2" title="2">{
var parsed interface{}
if err := json.Unmarshal([]byte(v), &amp;parsed); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config JSON: " + err.Error()})
return
}</span>
<span class="cov1" title="1">parsed = caddy.NormalizeAdvancedConfig(parsed)
if norm, err := json.Marshal(parsed); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()})
return
}</span> else<span class="cov1" title="1"> {
// Backup previous
host.AdvancedConfigBackup = host.AdvancedConfig
host.AdvancedConfig = string(norm)
}</span>
} else<span class="cov1" title="1"> if v == "" </span><span class="cov1" title="1">{ // allow clearing advanced config
host.AdvancedConfigBackup = host.AdvancedConfig
host.AdvancedConfig = ""
}</span>
}
<span class="cov7" title="11">if err := h.service.Update(host); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov7" title="11">if h.caddyManager != nil </span><span class="cov2" title="2">{
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}</span>
}
<span class="cov7" title="10">c.JSON(http.StatusOK, host)</span>
}
// Delete removes a proxy host.
func (h *ProxyHostHandler) Delete(c *gin.Context) <span class="cov5" title="5">{
uuid := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}</span>
// check if we should also delete associated uptime monitors (query param: delete_uptime=true)
<span class="cov4" title="4">deleteUptime := c.DefaultQuery("delete_uptime", "false") == "true"
if deleteUptime &amp;&amp; h.uptimeService != nil </span><span class="cov1" title="1">{
// Find all monitors referencing this proxy host and delete each
var monitors []models.UptimeMonitor
if err := h.uptimeService.DB.Where("proxy_host_id = ?", host.ID).Find(&amp;monitors).Error; err == nil </span><span class="cov1" title="1">{
for _, m := range monitors </span><span class="cov1" title="1">{
_ = h.uptimeService.DeleteMonitor(m.ID)
}</span>
}
}
<span class="cov4" title="4">if err := h.service.Delete(host.ID); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov4" title="4">if h.caddyManager != nil </span><span class="cov2" title="2">{
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}</span>
}
// Send Notification
<span class="cov4" title="3">if h.notificationService != nil </span><span class="cov4" title="3">{
h.notificationService.SendExternal(c.Request.Context(),
"proxy_host",
"Proxy Host Deleted",
fmt.Sprintf("Proxy Host %s deleted", host.Name),
map[string]interface{}{
"Name": host.Name,
"Action": "deleted",
},
)
}</span>
<span class="cov4" title="3">c.JSON(http.StatusOK, gin.H{"message": "proxy host deleted"})</span>
}
// TestConnection checks if the proxy host is reachable.
func (h *ProxyHostHandler) TestConnection(c *gin.Context) <span class="cov5" title="5">{
var req struct {
ForwardHost string `json:"forward_host" binding:"required"`
ForwardPort int `json:"forward_port" binding:"required"`
}
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov2" title="2">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov4" title="3">if err := h.service.TestConnection(req.ForwardHost, req.ForwardPort); err != nil </span><span class="cov2" title="2">{
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"message": "Connection successful"})</span>
}
// BulkUpdateACL applies or removes an access list to multiple proxy hosts.
func (h *ProxyHostHandler) BulkUpdateACL(c *gin.Context) <span class="cov5" title="5">{
var req struct {
HostUUIDs []string `json:"host_uuids" binding:"required"`
AccessListID *uint `json:"access_list_id"` // nil means remove ACL
}
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov4" title="4">if len(req.HostUUIDs) == 0 </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "host_uuids cannot be empty"})
return
}</span>
<span class="cov4" title="3">updated := 0
errors := []map[string]string{}
for _, uuid := range req.HostUUIDs </span><span class="cov5" title="5">{
host, err := h.service.GetByUUID(uuid)
if err != nil </span><span class="cov1" title="1">{
errors = append(errors, map[string]string{
"uuid": uuid,
"error": "proxy host not found",
})
continue</span>
}
<span class="cov4" title="4">host.AccessListID = req.AccessListID
if err := h.service.Update(host); err != nil </span><span class="cov0" title="0">{
errors = append(errors, map[string]string{
"uuid": uuid,
"error": err.Error(),
})
continue</span>
}
<span class="cov4" title="4">updated++</span>
}
// Apply Caddy config once for all updates
<span class="cov4" title="3">if updated &gt; 0 &amp;&amp; h.caddyManager != nil </span><span class="cov0" title="0">{
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to apply configuration: " + err.Error(),
"updated": updated,
"errors": errors,
})
return
}</span>
}
<span class="cov4" title="3">c.JSON(http.StatusOK, gin.H{
"updated": updated,
"errors": errors,
})</span>
}
</pre>
<pre class="file" id="file16" style="display: none">package handlers
import (
"fmt"
"net"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
// RemoteServerHandler handles HTTP requests for remote server management.
type RemoteServerHandler struct {
service *services.RemoteServerService
notificationService *services.NotificationService
}
// NewRemoteServerHandler creates a new remote server handler.
func NewRemoteServerHandler(service *services.RemoteServerService, ns *services.NotificationService) *RemoteServerHandler <span class="cov10" title="9">{
return &amp;RemoteServerHandler{
service: service,
notificationService: ns,
}
}</span>
// RegisterRoutes registers remote server routes.
func (h *RemoteServerHandler) RegisterRoutes(router *gin.RouterGroup) <span class="cov8" title="7">{
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/test", h.TestConnectionCustom)
router.POST("/remote-servers/:uuid/test", h.TestConnection)
}</span>
// List retrieves all remote servers.
func (h *RemoteServerHandler) List(c *gin.Context) <span class="cov3" title="2">{
enabledOnly := c.Query("enabled") == "true"
servers, err := h.service.List(enabledOnly)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, servers)</span>
}
// Create creates a new remote server.
func (h *RemoteServerHandler) Create(c *gin.Context) <span class="cov5" title="3">{
var server models.RemoteServer
if err := c.ShouldBindJSON(&amp;server); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov3" title="2">server.UUID = uuid.NewString()
if err := h.service.Create(&amp;server); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
// Send Notification
<span class="cov3" title="2">if h.notificationService != nil </span><span class="cov3" title="2">{
h.notificationService.SendExternal(c.Request.Context(),
"remote_server",
"Remote Server Added",
fmt.Sprintf("Remote Server %s (%s:%d) added", util.SanitizeForLog(server.Name), util.SanitizeForLog(server.Host), server.Port),
map[string]interface{}{
"Name": util.SanitizeForLog(server.Name),
"Host": util.SanitizeForLog(server.Host),
"Port": server.Port,
"Action": "created",
},
)
}</span>
<span class="cov3" title="2">c.JSON(http.StatusCreated, server)</span>
}
// Get retrieves a remote server by UUID.
func (h *RemoteServerHandler) Get(c *gin.Context) <span class="cov6" title="4">{
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil </span><span class="cov3" title="2">{
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, server)</span>
}
// Update updates an existing remote server.
func (h *RemoteServerHandler) Update(c *gin.Context) <span class="cov6" title="4">{
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil </span><span class="cov3" title="2">{
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}</span>
<span class="cov3" title="2">if err := c.ShouldBindJSON(server); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov3" title="2">if err := h.service.Update(server); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, server)</span>
}
// Delete removes a remote server.
func (h *RemoteServerHandler) Delete(c *gin.Context) <span class="cov6" title="4">{
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil </span><span class="cov3" title="2">{
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}</span>
<span class="cov3" title="2">if err := h.service.Delete(server.ID); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
// Send Notification
<span class="cov3" title="2">if h.notificationService != nil </span><span class="cov3" title="2">{
h.notificationService.SendExternal(c.Request.Context(),
"remote_server",
"Remote Server Deleted",
fmt.Sprintf("Remote Server %s deleted", util.SanitizeForLog(server.Name)),
map[string]interface{}{
"Name": util.SanitizeForLog(server.Name),
"Action": "deleted",
},
)
}</span>
<span class="cov3" title="2">c.JSON(http.StatusNoContent, nil)</span>
}
// TestConnection tests the TCP connection to a remote server.
func (h *RemoteServerHandler) TestConnection(c *gin.Context) <span class="cov1" title="1">{
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}</span>
// Test TCP connection with 5 second timeout
<span class="cov1" title="1">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 </span><span class="cov1" title="1">{
result["reachable"] = false
result["error"] = err.Error()
// Update server reachability status
server.Reachable = false
now := time.Now().UTC()
server.LastChecked = &amp;now
_ = h.service.Update(server)
c.JSON(http.StatusOK, result)
return
}</span>
<span class="cov0" title="0">defer func() </span><span class="cov0" title="0">{ _ = conn.Close() }</span>()
// Connection successful
<span class="cov0" title="0">result["reachable"] = true
result["latency_ms"] = time.Since(time.Now()).Milliseconds()
// Update server reachability status
server.Reachable = true
now := time.Now().UTC()
server.LastChecked = &amp;now
_ = h.service.Update(server)
c.JSON(http.StatusOK, result)</span>
}
// TestConnectionCustom tests connectivity to a host/port provided in the body
func (h *RemoteServerHandler) TestConnectionCustom(c *gin.Context) <span class="cov1" title="1">{
var req struct {
Host string `json:"host" binding:"required"`
Port int `json:"port" binding:"required"`
}
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
// Test TCP connection with 5 second timeout
<span class="cov1" title="1">address := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
start := time.Now()
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
result := gin.H{
"address": address,
"timestamp": time.Now().UTC(),
}
if err != nil </span><span class="cov1" title="1">{
result["reachable"] = false
result["error"] = err.Error()
c.JSON(http.StatusOK, result)
return
}</span>
<span class="cov0" title="0">defer func() </span><span class="cov0" title="0">{ _ = conn.Close() }</span>()
// Connection successful
<span class="cov0" title="0">result["reachable"] = true
result["latency_ms"] = time.Since(start).Milliseconds()
c.JSON(http.StatusOK, result)</span>
}
</pre>
<pre class="file" id="file17" style="display: none">package handlers
import (
"regexp"
"strings"
)
// sanitizeForLog removes control characters and newlines from user content before logging.
func sanitizeForLog(s string) string <span class="cov10" title="4">{
if s == "" </span><span class="cov0" title="0">{
return s
}</span>
// Replace CRLF and LF with spaces and remove other control chars
<span class="cov10" title="4">s = strings.ReplaceAll(s, "\r\n", " ")
s = strings.ReplaceAll(s, "\n", " ")
// remove any other non-printable control characters
re := regexp.MustCompile(`[\x00-\x1F\x7F]+`)
s = re.ReplaceAllString(s, " ")
return s</span>
}
</pre>
<pre class="file" id="file18" style="display: none">package handlers
import (
"errors"
"net"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)
// SecurityHandler handles security-related API requests.
type SecurityHandler struct {
cfg config.SecurityConfig
db *gorm.DB
svc *services.SecurityService
caddyManager *caddy.Manager
}
// NewSecurityHandler creates a new SecurityHandler.
func NewSecurityHandler(cfg config.SecurityConfig, db *gorm.DB, caddyManager *caddy.Manager) *SecurityHandler <span class="cov10" title="44">{
svc := services.NewSecurityService(db)
return &amp;SecurityHandler{cfg: cfg, db: db, svc: svc, caddyManager: caddyManager}
}</span>
// GetStatus returns the current status of all security services.
func (h *SecurityHandler) GetStatus(c *gin.Context) <span class="cov5" title="7">{
enabled := h.cfg.CerberusEnabled
// Check runtime setting override
var settingKey = "security.cerberus.enabled"
if h.db != nil </span><span class="cov4" title="5">{
var setting struct{ Value string }
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", settingKey).Scan(&amp;setting).Error; err == nil &amp;&amp; setting.Value != "" </span><span class="cov2" title="2">{
if strings.EqualFold(setting.Value, "true") </span><span class="cov1" title="1">{
enabled = true
}</span> else<span class="cov1" title="1"> {
enabled = false
}</span>
}
}
// Allow runtime overrides for CrowdSec mode + API URL via settings table
<span class="cov5" title="7">mode := h.cfg.CrowdSecMode
apiURL := h.cfg.CrowdSecAPIURL
if h.db != nil </span><span class="cov4" title="5">{
var m struct{ Value string }
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.mode").Scan(&amp;m).Error; err == nil &amp;&amp; m.Value != "" </span><span class="cov2" title="2">{
mode = m.Value
}</span>
<span class="cov4" title="5">var a struct{ Value string }
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.api_url").Scan(&amp;a).Error; err == nil &amp;&amp; a.Value != "" </span><span class="cov0" title="0">{
apiURL = a.Value
}</span>
}
// Only allow 'local' as an enabled mode. Any other value should be treated as disabled.
<span class="cov5" title="7">if mode != "local" </span><span class="cov5" title="6">{
mode = "disabled"
apiURL = ""
}</span>
// Allow runtime override for ACL enabled flag via settings table
<span class="cov5" title="7">aclEnabled := h.cfg.ACLMode == "enabled"
aclEffective := aclEnabled &amp;&amp; enabled
if h.db != nil </span><span class="cov4" title="5">{
var a struct{ Value string }
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.acl.enabled").Scan(&amp;a).Error; err == nil &amp;&amp; a.Value != "" </span><span class="cov2" title="2">{
if strings.EqualFold(a.Value, "true") </span><span class="cov2" title="2">{
aclEnabled = true
}</span> else<span class="cov0" title="0"> if strings.EqualFold(a.Value, "false") </span><span class="cov0" title="0">{
aclEnabled = false
}</span>
// If Cerberus is disabled, ACL should not be considered enabled even
// if the ACL setting is true. This keeps ACL tied to the Cerberus
// suite state in the UI and APIs.
<span class="cov2" title="2">aclEffective = aclEnabled &amp;&amp; enabled</span>
}
}
<span class="cov5" title="7">c.JSON(http.StatusOK, gin.H{
"cerberus": gin.H{"enabled": enabled},
"crowdsec": gin.H{
"mode": mode,
"api_url": apiURL,
"enabled": mode == "local",
},
"waf": gin.H{
"mode": h.cfg.WAFMode,
"enabled": h.cfg.WAFMode != "" &amp;&amp; h.cfg.WAFMode != "disabled",
},
"rate_limit": gin.H{
"mode": h.cfg.RateLimitMode,
"enabled": h.cfg.RateLimitMode == "enabled",
},
"acl": gin.H{
"mode": h.cfg.ACLMode,
"enabled": aclEffective,
},
})</span>
}
// GetConfig returns the site security configuration from DB or default
func (h *SecurityHandler) GetConfig(c *gin.Context) <span class="cov4" title="4">{
cfg, err := h.svc.Get()
if err != nil </span><span class="cov2" title="2">{
if err == services.ErrSecurityConfigNotFound </span><span class="cov2" title="2">{
c.JSON(http.StatusOK, gin.H{"config": nil})
return
}</span>
<span class="cov0" title="0">c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"})
return</span>
}
<span class="cov2" title="2">c.JSON(http.StatusOK, gin.H{"config": cfg})</span>
}
// UpdateConfig creates or updates the SecurityConfig in DB
func (h *SecurityHandler) UpdateConfig(c *gin.Context) <span class="cov4" title="4">{
var payload models.SecurityConfig
if err := c.ShouldBindJSON(&amp;payload); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}</span>
<span class="cov3" title="3">if payload.Name == "" </span><span class="cov1" title="1">{
payload.Name = "default"
}</span>
<span class="cov3" title="3">if err := h.svc.Upsert(&amp;payload); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
// Apply updated config to Caddy so WAF mode changes take effect
<span class="cov3" title="3">if h.caddyManager != nil </span><span class="cov0" title="0">{
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil </span><span class="cov0" title="0">{
log.WithError(err).Warn("failed to apply security config changes to Caddy")
}</span>
}
<span class="cov3" title="3">c.JSON(http.StatusOK, gin.H{"config": payload})</span>
}
// GenerateBreakGlass generates a break-glass token and returns the plaintext token once
func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) <span class="cov4" title="5">{
token, err := h.svc.GenerateBreakGlassToken("default")
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate break-glass token"})
return
}</span>
<span class="cov4" title="5">c.JSON(http.StatusOK, gin.H{"token": token})</span>
}
// ListDecisions returns recent security decisions
func (h *SecurityHandler) ListDecisions(c *gin.Context) <span class="cov3" title="3">{
limit := 50
if q := c.Query("limit"); q != "" </span><span class="cov2" title="2">{
if v, err := strconv.Atoi(q); err == nil </span><span class="cov2" title="2">{
limit = v
}</span>
}
<span class="cov3" title="3">list, err := h.svc.ListDecisions(limit)
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list decisions"})
return
}</span>
<span class="cov3" title="3">c.JSON(http.StatusOK, gin.H{"decisions": list})</span>
}
// CreateDecision creates a manual decision (override) - for now no checks besides payload
func (h *SecurityHandler) CreateDecision(c *gin.Context) <span class="cov4" title="5">{
var payload models.SecurityDecision
if err := c.ShouldBindJSON(&amp;payload); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}</span>
<span class="cov4" title="4">if payload.IP == "" || payload.Action == "" </span><span class="cov2" title="2">{
c.JSON(http.StatusBadRequest, gin.H{"error": "ip and action are required"})
return
}</span>
// Populate source
<span class="cov2" title="2">payload.Source = "manual"
if err := h.svc.LogDecision(&amp;payload); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to log decision"})
return
}</span>
// Record an audit entry
<span class="cov2" title="2">actor := c.GetString("user_id")
if actor == "" </span><span class="cov2" title="2">{
actor = c.ClientIP()
}</span>
<span class="cov2" title="2">_ = h.svc.LogAudit(&amp;models.SecurityAudit{Actor: actor, Action: "create_decision", Details: payload.Details})
c.JSON(http.StatusOK, gin.H{"decision": payload})</span>
}
// ListRuleSets returns the list of known rulesets
func (h *SecurityHandler) ListRuleSets(c *gin.Context) <span class="cov2" title="2">{
list, err := h.svc.ListRuleSets()
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list rule sets"})
return
}</span>
<span class="cov2" title="2">c.JSON(http.StatusOK, gin.H{"rulesets": list})</span>
}
// UpsertRuleSet uploads or updates a ruleset
func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) <span class="cov4" title="5">{
var payload models.SecurityRuleSet
if err := c.ShouldBindJSON(&amp;payload); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}</span>
<span class="cov4" title="4">if payload.Name == "" </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
return
}</span>
<span class="cov3" title="3">if err := h.svc.UpsertRuleSet(&amp;payload); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upsert ruleset"})
return
}</span>
<span class="cov3" title="3">if h.caddyManager != nil </span><span class="cov1" title="1">{
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}</span>
}
// Create an audit event
<span class="cov3" title="3">actor := c.GetString("user_id")
if actor == "" </span><span class="cov3" title="3">{
actor = c.ClientIP()
}</span>
<span class="cov3" title="3">_ = h.svc.LogAudit(&amp;models.SecurityAudit{Actor: actor, Action: "upsert_ruleset", Details: payload.Name})
c.JSON(http.StatusOK, gin.H{"ruleset": payload})</span>
}
// DeleteRuleSet removes a ruleset by id
func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) <span class="cov4" title="5">{
idParam := c.Param("id")
if idParam == "" </span><span class="cov0" title="0">{
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}</span>
<span class="cov4" title="5">id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}</span>
<span class="cov4" title="4">if err := h.svc.DeleteRuleSet(uint(id)); err != nil </span><span class="cov1" title="1">{
if errors.Is(err, gorm.ErrRecordNotFound) </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "ruleset not found"})
return
}</span>
<span class="cov0" title="0">c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete ruleset"})
return</span>
}
<span class="cov3" title="3">if h.caddyManager != nil </span><span class="cov1" title="1">{
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}</span>
}
<span class="cov3" title="3">actor := c.GetString("user_id")
if actor == "" </span><span class="cov3" title="3">{
actor = c.ClientIP()
}</span>
<span class="cov3" title="3">_ = h.svc.LogAudit(&amp;models.SecurityAudit{Actor: actor, Action: "delete_ruleset", Details: idParam})
c.JSON(http.StatusOK, gin.H{"deleted": true})</span>
}
// Enable toggles Cerberus on, validating admin whitelist or break-glass token
func (h *SecurityHandler) Enable(c *gin.Context) <span class="cov6" title="9">{
// Look for requester's IP and optional breakglass token
adminIP := c.ClientIP()
var body struct {
Token string `json:"break_glass_token"`
}
_ = c.ShouldBindJSON(&amp;body)
// If config exists, require that adminIP is in whitelist or token matches
cfg, err := h.svc.Get()
if err != nil &amp;&amp; err != services.ErrSecurityConfigNotFound </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve security config"})
return
}</span>
<span class="cov6" title="9">if cfg != nil </span><span class="cov5" title="8">{
// Check admin whitelist
if cfg.AdminWhitelist == "" &amp;&amp; body.Token == "" </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "admin whitelist missing; provide break_glass_token or add admin_whitelist CIDR before enabling"})
return
}</span>
<span class="cov5" title="7">if body.Token != "" </span><span class="cov2" title="2">{
ok, err := h.svc.VerifyBreakGlassToken(cfg.Name, body.Token)
if err == nil &amp;&amp; ok </span>{<span class="cov1" title="1">
// proceed
}</span> else<span class="cov1" title="1"> {
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token invalid"})
return
}</span>
} else<span class="cov4" title="5"> {
// verify client IP in admin whitelist
found := false
for _, entry := range strings.Split(cfg.AdminWhitelist, ",") </span><span class="cov4" title="5">{
entry = strings.TrimSpace(entry)
if entry == "" </span><span class="cov0" title="0">{
continue</span>
}
<span class="cov4" title="5">if entry == adminIP </span><span class="cov2" title="2">{
found = true
break</span>
}
// If CIDR, check contains
<span class="cov3" title="3">if _, cidr, err := net.ParseCIDR(entry); err == nil </span><span class="cov3" title="3">{
if cidr.Contains(net.ParseIP(adminIP)) </span><span class="cov2" title="2">{
found = true
break</span>
}
}
}
<span class="cov4" title="5">if !found </span><span class="cov1" title="1">{
c.JSON(http.StatusForbidden, gin.H{"error": "admin IP not present in admin_whitelist"})
return
}</span>
}
}
// Set enabled true
<span class="cov5" title="6">newCfg := &amp;models.SecurityConfig{Name: "default", Enabled: true}
if cfg != nil </span><span class="cov4" title="5">{
newCfg = cfg
newCfg.Enabled = true
}</span>
<span class="cov5" title="6">if err := h.svc.Upsert(newCfg); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable Cerberus"})
return
}</span>
<span class="cov5" title="6">if h.caddyManager != nil </span><span class="cov0" title="0">{
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}</span>
}
<span class="cov5" title="6">c.JSON(http.StatusOK, gin.H{"enabled": true})</span>
}
// Disable toggles Cerberus off; requires break-glass token or localhost request
func (h *SecurityHandler) Disable(c *gin.Context) <span class="cov5" title="6">{
var body struct {
Token string `json:"break_glass_token"`
}
_ = c.ShouldBindJSON(&amp;body)
// Allow requests from localhost to disable without token
clientIP := c.ClientIP()
if clientIP == "127.0.0.1" || clientIP == "::1" </span><span class="cov2" title="2">{
cfg, _ := h.svc.Get()
if cfg == nil </span><span class="cov0" title="0">{
cfg = &amp;models.SecurityConfig{Name: "default", Enabled: false}
}</span> else<span class="cov2" title="2"> {
cfg.Enabled = false
}</span>
<span class="cov2" title="2">_ = h.svc.Upsert(cfg)
if h.caddyManager != nil </span><span class="cov0" title="0">{
_ = h.caddyManager.ApplyConfig(c.Request.Context())
}</span>
<span class="cov2" title="2">c.JSON(http.StatusOK, gin.H{"enabled": false})
return</span>
}
<span class="cov4" title="4">cfg, err := h.svc.Get()
if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read config"})
return
}</span>
<span class="cov4" title="4">if body.Token == "" </span><span class="cov1" title="1">{
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token required to disable Cerberus from non-localhost"})
return
}</span>
<span class="cov3" title="3">ok, err := h.svc.VerifyBreakGlassToken(cfg.Name, body.Token)
if err != nil || !ok </span><span class="cov1" title="1">{
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token invalid"})
return
}</span>
<span class="cov2" title="2">cfg.Enabled = false
_ = h.svc.Upsert(cfg)
if h.caddyManager != nil </span><span class="cov0" title="0">{
_ = h.caddyManager.ApplyConfig(c.Request.Context())
}</span>
<span class="cov2" title="2">c.JSON(http.StatusOK, gin.H{"enabled": false})</span>
}
</pre>
<pre class="file" id="file19" style="display: none">package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/Wikid82/charon/backend/internal/config"
)
func TestSecurityHandler_GetStatus_Fixed(t *testing.T) <span class="cov0" title="0">{
gin.SetMode(gin.TestMode)
tests := []struct {
name string
cfg config.SecurityConfig
expectedStatus int
expectedBody map[string]interface{}
}{
{
name: "All Disabled",
cfg: config.SecurityConfig{
CrowdSecMode: "disabled",
WAFMode: "disabled",
RateLimitMode: "disabled",
ACLMode: "disabled",
},
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"cerberus": map[string]interface{}{"enabled": false},
"crowdsec": map[string]interface{}{
"mode": "disabled",
"api_url": "",
"enabled": false,
},
"waf": map[string]interface{}{
"mode": "disabled",
"enabled": false,
},
"rate_limit": map[string]interface{}{
"mode": "disabled",
"enabled": false,
},
"acl": map[string]interface{}{
"mode": "disabled",
"enabled": false,
},
},
},
{
name: "All Enabled",
cfg: config.SecurityConfig{
CrowdSecMode: "local",
WAFMode: "enabled",
RateLimitMode: "enabled",
ACLMode: "enabled",
},
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"cerberus": map[string]interface{}{"enabled": true},
"crowdsec": map[string]interface{}{
"mode": "local",
"api_url": "",
"enabled": true,
},
"waf": map[string]interface{}{
"mode": "enabled",
"enabled": true,
},
"rate_limit": map[string]interface{}{
"mode": "enabled",
"enabled": true,
},
"acl": map[string]interface{}{
"mode": "enabled",
"enabled": true,
},
},
},
}
for _, tt := range tests </span><span class="cov0" title="0">{
t.Run(tt.name, func(t *testing.T) </span><span class="cov0" title="0">{
handler := NewSecurityHandler(tt.cfg, nil, nil)
router := gin.New()
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &amp;response)
assert.NoError(t, err)
expectedJSON, _ := json.Marshal(tt.expectedBody)
var expectedNormalized map[string]interface{}
if err := json.Unmarshal(expectedJSON, &amp;expectedNormalized); err != nil </span><span class="cov0" title="0">{
t.Fatalf("failed to unmarshal expected JSON: %v", err)
}</span>
<span class="cov0" title="0">assert.Equal(t, expectedNormalized, response)</span>
})
}
}
</pre>
<pre class="file" id="file20" style="display: none">package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
type SettingsHandler struct {
DB *gorm.DB
}
func NewSettingsHandler(db *gorm.DB) *SettingsHandler <span class="cov8" title="3">{
return &amp;SettingsHandler{DB: db}
}</span>
// GetSettings returns all settings.
func (h *SettingsHandler) GetSettings(c *gin.Context) <span class="cov1" title="1">{
var settings []models.Setting
if err := h.DB.Find(&amp;settings).Error; err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
}</span>
// Convert to map for easier frontend consumption
<span class="cov1" title="1">settingsMap := make(map[string]string)
for _, s := range settings </span><span class="cov1" title="1">{
settingsMap[s.Key] = s.Value
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, settingsMap)</span>
}
type UpdateSettingRequest struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
Category string `json:"category"`
Type string `json:"type"`
}
// UpdateSetting updates or creates a setting.
func (h *SettingsHandler) UpdateSetting(c *gin.Context) <span class="cov10" title="4">{
var req UpdateSettingRequest
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov5" title="2">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov5" title="2">setting := models.Setting{
Key: req.Key,
Value: req.Value,
}
if req.Category != "" </span><span class="cov5" title="2">{
setting.Category = req.Category
}</span>
<span class="cov5" title="2">if req.Type != "" </span><span class="cov5" title="2">{
setting.Type = req.Type
}</span>
// Upsert
<span class="cov5" title="2">if err := h.DB.Where(models.Setting{Key: req.Key}).Assign(setting).FirstOrCreate(&amp;setting).Error; err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save setting"})
return
}</span>
<span class="cov5" title="2">c.JSON(http.StatusOK, setting)</span>
}
</pre>
<pre class="file" id="file21" style="display: none">package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
type SystemHandler struct{}
func NewSystemHandler() *SystemHandler <span class="cov1" title="1">{
return &amp;SystemHandler{}
}</span>
type MyIPResponse struct {
IP string `json:"ip"`
Source string `json:"source"`
}
// GetMyIP returns the client's public IP address
func (h *SystemHandler) GetMyIP(c *gin.Context) <span class="cov1" title="1">{
// Try to get the real IP from various headers (in order of preference)
// This handles proxies, load balancers, and CDNs
ip := getClientIP(c.Request)
source := "direct"
if c.GetHeader("X-Forwarded-For") != "" </span><span class="cov0" title="0">{
source = "X-Forwarded-For"
}</span> else<span class="cov1" title="1"> if c.GetHeader("X-Real-IP") != "" </span><span class="cov0" title="0">{
source = "X-Real-IP"
}</span> else<span class="cov1" title="1"> if c.GetHeader("CF-Connecting-IP") != "" </span><span class="cov1" title="1">{
source = "Cloudflare"
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, MyIPResponse{
IP: ip,
Source: source,
})</span>
}
// getClientIP extracts the real client IP from the request
// Checks headers in order of trust/reliability
func getClientIP(r *http.Request) string <span class="cov10" title="5">{
// Cloudflare
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" </span><span class="cov4" title="2">{
return ip
}</span>
// Other CDNs/proxies
<span class="cov7" title="3">if ip := r.Header.Get("X-Real-IP"); ip != "" </span><span class="cov1" title="1">{
return ip
}</span>
// Standard proxy header (can be a comma-separated list)
<span class="cov4" title="2">if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" </span><span class="cov1" title="1">{
// Take the first IP in the list (client IP)
ips := strings.Split(forwarded, ",")
if len(ips) &gt; 0 </span><span class="cov1" title="1">{
return strings.TrimSpace(ips[0])
}</span>
}
// Fallback to RemoteAddr (format: "IP:port")
<span class="cov1" title="1">if ip := r.RemoteAddr; ip != "" </span><span class="cov1" title="1">{
// Remove port if present
if idx := strings.LastIndex(ip, ":"); idx != -1 </span><span class="cov1" title="1">{
return ip[:idx]
}</span>
<span class="cov0" title="0">return ip</span>
}
<span class="cov0" title="0">return "unknown"</span>
}
</pre>
<pre class="file" id="file22" style="display: none">package handlers
import (
"fmt"
"math/rand"
"strings"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// openTestDB creates a SQLite in-memory DB unique per test and applies
// a busy timeout and WAL journal mode to reduce SQLITE locking during parallel tests.
func OpenTestDB(t *testing.T) *gorm.DB <span class="cov10" title="65">{
t.Helper()
// Append a timestamp/random suffix to ensure uniqueness even across parallel runs
dsnName := strings.ReplaceAll(t.Name(), "/", "_")
rand.Seed(time.Now().UnixNano())
uniqueSuffix := fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Intn(10000))
dsn := fmt.Sprintf("file:%s_%s?mode=memory&amp;cache=shared&amp;_journal_mode=WAL&amp;_busy_timeout=5000", dsnName, uniqueSuffix)
db, err := gorm.Open(sqlite.Open(dsn), &amp;gorm.Config{})
if err != nil </span><span class="cov0" title="0">{
t.Fatalf("failed to open test db: %v", err)
}</span>
<span class="cov10" title="65">return db</span>
}
</pre>
<pre class="file" id="file23" style="display: none">package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type UpdateHandler struct {
service *services.UpdateService
}
func NewUpdateHandler(service *services.UpdateService) *UpdateHandler <span class="cov10" title="3">{
return &amp;UpdateHandler{service: service}
}</span>
func (h *UpdateHandler) Check(c *gin.Context) <span class="cov10" title="3">{
info, err := h.service.CheckForUpdates()
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check for updates"})
return
}</span>
<span class="cov6" title="2">c.JSON(http.StatusOK, info)</span>
}
</pre>
<pre class="file" id="file24" style="display: none">package handlers
import (
"net/http"
"strconv"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type UptimeHandler struct {
service *services.UptimeService
}
func NewUptimeHandler(service *services.UptimeService) *UptimeHandler <span class="cov10" title="14">{
return &amp;UptimeHandler{service: service}
}</span>
func (h *UptimeHandler) List(c *gin.Context) <span class="cov3" title="2">{
monitors, err := h.service.ListMonitors()
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list monitors"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, monitors)</span>
}
func (h *UptimeHandler) GetHistory(c *gin.Context) <span class="cov3" title="2">{
id := c.Param("id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
history, err := h.service.GetMonitorHistory(id, limit)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, history)</span>
}
func (h *UptimeHandler) Update(c *gin.Context) <span class="cov5" title="4">{
id := c.Param("id")
var updates map[string]interface{}
if err := c.ShouldBindJSON(&amp;updates); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
<span class="cov4" title="3">monitor, err := h.service.UpdateMonitor(id, updates)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, monitor)</span>
}
func (h *UptimeHandler) Sync(c *gin.Context) <span class="cov3" title="2">{
if err := h.service.SyncMonitors(); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync monitors"})
return
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, gin.H{"message": "Sync started"})</span>
}
// Delete removes a monitor and its associated data
func (h *UptimeHandler) Delete(c *gin.Context) <span class="cov3" title="2">{
id := c.Param("id")
if err := h.service.DeleteMonitor(id); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete monitor"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"message": "Monitor deleted"})</span>
}
// CheckMonitor triggers an immediate check for a specific monitor
func (h *UptimeHandler) CheckMonitor(c *gin.Context) <span class="cov3" title="2">{
id := c.Param("id")
monitor, err := h.service.GetMonitorByID(id)
if err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "Monitor not found"})
return
}</span>
// Trigger immediate check in background
<span class="cov1" title="1">go h.service.CheckMonitor(*monitor)
c.JSON(http.StatusOK, gin.H{"message": "Check triggered"})</span>
}
</pre>
<pre class="file" id="file25" style="display: none">package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
type UserHandler struct {
DB *gorm.DB
}
func NewUserHandler(db *gorm.DB) *UserHandler <span class="cov10" title="9">{
return &amp;UserHandler{DB: db}
}</span>
func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) <span class="cov1" title="1">{
r.GET("/setup", h.GetSetupStatus)
r.POST("/setup", h.Setup)
r.GET("/profile", h.GetProfile)
r.POST("/regenerate-api-key", h.RegenerateAPIKey)
r.PUT("/profile", h.UpdateProfile)
}</span>
// GetSetupStatus checks if the application needs initial setup (i.e., no users exist).
func (h *UserHandler) GetSetupStatus(c *gin.Context) <span class="cov3" title="2">{
var count int64
if err := h.DB.Model(&amp;models.User{}).Count(&amp;count).Error; err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
return
}</span>
<span class="cov3" title="2">c.JSON(http.StatusOK, gin.H{
"setupRequired": count == 0,
})</span>
}
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) <span class="cov5" title="3">{
// 1. Check if setup is allowed
var count int64
if err := h.DB.Model(&amp;models.User{}).Count(&amp;count).Error; err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
return
}</span>
<span class="cov5" title="3">if count &gt; 0 </span><span class="cov1" title="1">{
c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"})
return
}</span>
// 2. Parse request
<span class="cov3" title="2">var req SetupRequest
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
// 3. Create User
<span class="cov1" title="1">user := models.User{
UUID: uuid.New().String(),
Name: req.Name,
Email: strings.ToLower(req.Email),
Role: "admin",
Enabled: true,
APIKey: uuid.New().String(),
}
if err := user.SetPassword(req.Password); err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}</span>
// 4. Create Setting for ACME Email
<span class="cov1" title="1">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 </span><span class="cov1" title="1">{
if err := tx.Create(&amp;user).Error; err != nil </span><span class="cov0" title="0">{
return err
}</span>
// Use Save to update if exists (though it shouldn't in fresh setup) or create
<span class="cov1" title="1">if err := tx.Where(models.Setting{Key: "caddy.acme_email"}).Assign(models.Setting{Value: req.Email}).FirstOrCreate(&amp;acmeEmailSetting).Error; err != nil </span><span class="cov0" title="0">{
return err
}</span>
<span class="cov1" title="1">return nil</span>
})
<span class="cov1" title="1">if err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusCreated, gin.H{
"message": "Setup completed successfully",
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
})</span>
}
// RegenerateAPIKey generates a new API key for the authenticated user.
func (h *UserHandler) RegenerateAPIKey(c *gin.Context) <span class="cov5" title="3">{
userID, exists := c.Get("userID")
if !exists </span><span class="cov1" title="1">{
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}</span>
<span class="cov3" title="2">apiKey := uuid.New().String()
if err := h.DB.Model(&amp;models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{"api_key": apiKey})</span>
}
// GetProfile returns the current user's profile including API key.
func (h *UserHandler) GetProfile(c *gin.Context) <span class="cov5" title="3">{
userID, exists := c.Get("userID")
if !exists </span><span class="cov1" title="1">{
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}</span>
<span class="cov3" title="2">var user models.User
if err := h.DB.First(&amp;user, userID).Error; err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}</span>
<span class="cov1" title="1">c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"api_key": user.APIKey,
})</span>
}
type UpdateProfileRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
CurrentPassword string `json:"current_password"`
}
// UpdateProfile updates the authenticated user's profile.
func (h *UserHandler) UpdateProfile(c *gin.Context) <span class="cov10" title="9">{
userID, exists := c.Get("userID")
if !exists </span><span class="cov1" title="1">{
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}</span>
<span class="cov9" title="8">var req UpdateProfileRequest
if err := c.ShouldBindJSON(&amp;req); err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}</span>
// Get current user
<span class="cov8" title="7">var user models.User
if err := h.DB.First(&amp;user, userID).Error; err != nil </span><span class="cov1" title="1">{
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}</span>
// Check if email is already taken by another user
<span class="cov8" title="6">req.Email = strings.ToLower(req.Email)
var count int64
if err := h.DB.Model(&amp;models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&amp;count).Error; err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email availability"})
return
}</span>
<span class="cov8" title="6">if count &gt; 0 </span><span class="cov1" title="1">{
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
return
}</span>
// If email is changing, verify password
<span class="cov7" title="5">if req.Email != user.Email </span><span class="cov6" title="4">{
if req.CurrentPassword == "" </span><span class="cov1" title="1">{
c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is required to change email"})
return
}</span>
<span class="cov5" title="3">if !user.CheckPassword(req.CurrentPassword) </span><span class="cov1" title="1">{
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
return
}</span>
}
<span class="cov5" title="3">if err := h.DB.Model(&amp;models.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"name": req.Name,
"email": req.Email,
}).Error; err != nil </span><span class="cov0" title="0">{
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
return
}</span>
<span class="cov5" title="3">c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"})</span>
}
</pre>
</div>
</body>
<script>
(function() {
var files = document.getElementById('files');
var visible;
files.addEventListener('change', onChange, false);
function select(part) {
if (visible)
visible.style.display = 'none';
visible = document.getElementById(part);
if (!visible)
return;
files.value = part;
visible.style.display = 'block';
location.hash = part;
}
function onChange() {
select(files.value);
window.scrollTo(0, 0);
}
if (location.hash != "") {
select(location.hash.substr(1));
}
if (!visible) {
select("file0");
}
})();
</script>
</html>