- 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.
4290 lines
199 KiB
HTML
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 &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(&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(&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(&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), &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(&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 &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(&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(&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(&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 &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 &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 &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 &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 := &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(&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 &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 &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(&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(&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(&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(&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(&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 &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 -> 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(&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(&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(&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 && !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 &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(&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(&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 && 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(&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), &result); err == nil </span><span class="cov3" title="3">{
|
|
// Update status to reviewing
|
|
session.Status = "reviewing"
|
|
h.db.Save(&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(&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 && 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(&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) > 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/<uuid>.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) > 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) > 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) > 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(&req); err != nil </span><span class="cov1" title="1">{
|
|
entry := middleware.GetRequestLogger(c)
|
|
if raw, _ := c.GetRawData(); len(raw) > 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) > 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(&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) > 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) > 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 -> action (keep/skip, overwrite, rename)
|
|
Names map[string]string `json:"names"` // domain -> custom name
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&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(&session).Error; err == nil </span><span class="cov2" title="2">{
|
|
// DB session found
|
|
if err := json.Unmarshal([]byte(session.ParsedData), &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 && 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] = &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 && 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(&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(&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 = &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(&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(&session).Error; err == nil </span><span class="cov1" title="1">{
|
|
session.Status = "rejected"
|
|
h.db.Save(&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(&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(&models.ImportSession{}).Where("source_file = ? AND status IN ?",
|
|
mountPath, []string{"pending", "reviewing", "committed"}).Count(&count)
|
|
|
|
if count > 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 &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 &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 &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(&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(&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(&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(&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(&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(&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, &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 &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(&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(&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(&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(&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(&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 && 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 &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(&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), &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(&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(&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 = &id</span>
|
|
case int:<span class="cov0" title="0">
|
|
id := uint(t)
|
|
host.CertificateID = &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 = &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 = &id</span>
|
|
case int:<span class="cov0" title="0">
|
|
id := uint(t)
|
|
host.AccessListID = &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 = &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, &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 != "" && v != host.AdvancedConfig </span><span class="cov2" title="2">{
|
|
var parsed interface{}
|
|
if err := json.Unmarshal([]byte(v), &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 && 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(&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(&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(&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 > 0 && 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 &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(&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(&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 = &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 = &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(&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 &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(&setting).Error; err == nil && 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(&m).Error; err == nil && 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(&a).Error; err == nil && 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 && 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(&a).Error; err == nil && 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 && 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 != "" && 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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&body)
|
|
|
|
// If config exists, require that adminIP is in whitelist or token matches
|
|
cfg, err := h.svc.Get()
|
|
if err != nil && 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 == "" && 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 && 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 := &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(&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 = &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(), &response)
|
|
assert.NoError(t, err)
|
|
|
|
expectedJSON, _ := json.Marshal(tt.expectedBody)
|
|
var expectedNormalized map[string]interface{}
|
|
if err := json.Unmarshal(expectedJSON, &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 &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(&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(&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(&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 &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) > 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&cache=shared&_journal_mode=WAL&_busy_timeout=5000", dsnName, uniqueSuffix)
|
|
db, err := gorm.Open(sqlite.Open(dsn), &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 &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 &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(&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 &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(&models.User{}).Count(&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(&models.User{}).Count(&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 > 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(&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(&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(&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(&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(&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(&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(&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(&models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&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 > 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(&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>
|