feat: add certificate management security and cleanup dialog

- Documented certificate management security features in security.md, including backup and recovery processes.
- Implemented CertificateCleanupDialog component for confirming deletion of orphaned certificates when deleting proxy hosts.
- Enhanced ProxyHosts page to check for orphaned certificates and prompt users accordingly during deletion.
- Added tests for certificate cleanup prompts and behaviors in ProxyHosts, ensuring correct handling of unique, shared, and production certificates.
This commit is contained in:
GitHub Actions
2025-12-06 01:43:46 +00:00
parent bd5b3b31bf
commit 8e2ba14ae5
17 changed files with 2051 additions and 41 deletions
+1 -3
View File
@@ -8,7 +8,6 @@
]
,
"gopls": {
"buildFlags": ["-tags=ignore", "-mod=mod"],
"env": {
"GOWORK": "off",
"GOFLAGS": "-mod=mod",
@@ -17,8 +16,7 @@
"directoryFilters": [
"-**/pkg/mod/**",
"-**/go/pkg/mod/**",
"-**/root/go/pkg/mod/**",
"-**/golang.org/toolchain@**"
"-**/root/go/pkg/mod/**"
]
},
"go.buildFlags": ["-tags=ignore", "-mod=mod"],
-1
View File
@@ -80,7 +80,6 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/mod v0.29.0 // indirect
-16
View File
@@ -10,8 +10,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
@@ -49,8 +47,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
@@ -70,14 +66,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
@@ -173,8 +165,6 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -231,14 +221,10 @@ go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOV
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
@@ -270,8 +256,6 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -4,9 +4,12 @@ import (
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
@@ -18,26 +21,38 @@ type BackupServiceInterface interface {
DeleteBackup(filename string) error
GetBackupPath(filename string) (string, error)
RestoreBackup(filename string) error
GetAvailableSpace() (int64, error)
}
type CertificateHandler struct {
service *services.CertificateService
backupService BackupServiceInterface
notificationService *services.NotificationService
// Rate limiting for notifications
notificationMu sync.Mutex
lastNotificationTime map[uint]time.Time
}
func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler {
return &CertificateHandler{
service: service,
backupService: backupService,
notificationService: ns,
service: service,
backupService: backupService,
notificationService: ns,
lastNotificationTime: make(map[uint]time.Time),
}
}
func (h *CertificateHandler) List(c *gin.Context) {
// Defense in depth - verify user context exists
if _, exists := c.Get("user"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
certs, err := h.service.ListCertificates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
logger.Log().WithError(err).Error("failed to list certificates")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list certificates"})
return
}
@@ -51,6 +66,12 @@ type UploadCertificateRequest struct {
}
func (h *CertificateHandler) Upload(c *gin.Context) {
// Defense in depth - verify user context exists
if _, exists := c.Get("user"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
// Handle multipart form
name := c.PostForm("name")
if name == "" {
@@ -98,7 +119,8 @@ func (h *CertificateHandler) Upload(c *gin.Context) {
cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
logger.Log().WithError(err).Error("failed to upload certificate")
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload certificate"})
return
}
@@ -120,6 +142,12 @@ func (h *CertificateHandler) Upload(c *gin.Context) {
}
func (h *CertificateHandler) Delete(c *gin.Context) {
// Defense in depth - verify user context exists
if _, exists := c.Get("user"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
@@ -127,9 +155,16 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
return
}
// Validate ID range
if id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
// Check if certificate is in use before proceeding
inUse, err := h.service.IsCertificateInUse(uint(id))
if err != nil {
logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to check certificate usage")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"})
return
}
@@ -140,7 +175,17 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
// Create backup before deletion
if h.backupService != nil {
// Check disk space before backup (require at least 100MB free)
if availableSpace, err := h.backupService.GetAvailableSpace(); err != nil {
logger.Log().WithError(err).Warn("unable to check disk space, proceeding with backup")
} else if availableSpace < 100*1024*1024 {
logger.Log().WithField("available_bytes", availableSpace).Warn("low disk space, skipping backup")
c.JSON(http.StatusInsufficientStorage, gin.H{"error": "insufficient disk space for backup"})
return
}
if _, err := h.backupService.CreateBackup(); err != nil {
logger.Log().WithError(err).Error("failed to create backup before deletion")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup before deletion"})
return
}
@@ -152,21 +197,31 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to delete certificate")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"})
return
}
// Send Notification
// Send Notification with rate limiting (1 per cert per 10 seconds)
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"cert",
"Certificate Deleted",
fmt.Sprintf("Certificate ID %d deleted", id),
map[string]interface{}{
"ID": id,
"Action": "deleted",
},
)
h.notificationMu.Lock()
lastTime, exists := h.lastNotificationTime[uint(id)]
if !exists || time.Since(lastTime) > 10*time.Second {
h.lastNotificationTime[uint(id)] = time.Now()
h.notificationMu.Unlock()
h.notificationService.SendExternal(c.Request.Context(),
"cert",
"Certificate Deleted",
fmt.Sprintf("Certificate ID %d deleted", id),
map[string]interface{}{
"ID": id,
"Action": "deleted",
},
)
} else {
h.notificationMu.Unlock()
logger.Log().WithField("certificate_id", id).Debug("notification rate limited")
}
}
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
@@ -21,6 +21,7 @@ func TestCertificateHandler_List_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
@@ -38,6 +39,7 @@ func TestCertificateHandler_Delete_InvalidID(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -56,6 +58,7 @@ func TestCertificateHandler_Delete_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -78,6 +81,7 @@ func TestCertificateHandler_Delete_NoBackupService(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
// Wait for background sync goroutine to complete to avoid race with -race flag
// NewCertificateService spawns a goroutine that immediately queries the DB
@@ -115,6 +119,7 @@ func TestCertificateHandler_Delete_CheckUsageDBError(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
@@ -137,6 +142,7 @@ func TestCertificateHandler_List_WithCertificates(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
@@ -0,0 +1,199 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)
// TestCertificateHandler_Delete_RequiresAuth tests that delete requires authentication
func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
// Note: NOT adding mockAuthMiddleware here to test auth requirement
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, "/api/certificates/1", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized without auth, got %d", w.Code)
}
}
// TestCertificateHandler_List_RequiresAuth tests that list requires authentication
func TestCertificateHandler_List_RequiresAuth(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
// Note: NOT adding mockAuthMiddleware here to test auth requirement
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
req := httptest.NewRequest(http.MethodGet, "/api/certificates", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized without auth, got %d", w.Code)
}
}
// TestCertificateHandler_Upload_RequiresAuth tests that upload requires authentication
func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
// Note: NOT adding mockAuthMiddleware here to test auth requirement
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
req := httptest.NewRequest(http.MethodPost, "/api/certificates", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized without auth, got %d", w.Code)
}
}
// TestCertificateHandler_Delete_DiskSpaceCheck tests the disk space check before backup
func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
// Create a certificate
cert := models.SSLCertificate{
UUID: "test-cert",
Name: "test",
Provider: "custom",
Domains: "test.com",
}
if err := db.Create(&cert).Error; err != nil {
t.Fatalf("failed to create cert: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
// Mock backup service that reports low disk space
mockBackup := &mockBackupService{
availableSpaceFunc: func() (int64, error) {
return 50 * 1024 * 1024, nil // 50MB (less than 100MB required)
},
}
h := NewCertificateHandler(svc, mockBackup, nil)
r.DELETE("/api/certificates/:id", h.Delete)
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert.ID), nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusInsufficientStorage {
t.Fatalf("expected 507 Insufficient Storage with low disk space, got %d", w.Code)
}
}
// TestCertificateHandler_Delete_NotificationRateLimiting tests rate limiting
func TestCertificateHandler_Delete_NotificationRateLimiting(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
// Create certificates
cert1 := models.SSLCertificate{UUID: "test-1", Name: "test1", Provider: "custom", Domains: "test1.com"}
cert2 := models.SSLCertificate{UUID: "test-2", Name: "test2", Provider: "custom", Domains: "test2.com"}
if err := db.Create(&cert1).Error; err != nil {
t.Fatalf("failed to create cert1: %v", err)
}
if err := db.Create(&cert2).Error; err != nil {
t.Fatalf("failed to create cert2: %v", err)
}
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
mockBackup := &mockBackupService{
createFunc: func() (string, error) {
return "backup.zip", nil
},
}
h := NewCertificateHandler(svc, mockBackup, nil)
r.DELETE("/api/certificates/:id", h.Delete)
// Delete first cert
req1 := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert1.ID), nil)
w1 := httptest.NewRecorder()
r.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("first delete failed: got %d", w1.Code)
}
// Delete second cert (different ID, should not be rate limited)
req2 := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert2.ID), nil)
w2 := httptest.NewRecorder()
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("second delete failed: got %d", w2.Code)
}
// The test passes if both deletions succeed
// Rate limiting is per-certificate ID, so different certs should not interfere
}
@@ -24,10 +24,20 @@ import (
"github.com/Wikid82/charon/backend/internal/services"
)
// mockAuthMiddleware adds a mock user to the context for testing
func mockAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("user", map[string]interface{}{"id": 1, "username": "testuser"})
c.Next()
}
}
func setupCertTestRouter(t *testing.T, db *gorm.DB) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
@@ -92,6 +102,7 @@ func TestDeleteCertificate_CreatesBackup(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
// Mock BackupService
@@ -145,6 +156,7 @@ func TestDeleteCertificate_BackupFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
// Mock BackupService that fails
@@ -198,6 +210,7 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
// Mock BackupService
@@ -227,7 +240,8 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) {
// Mock BackupService for testing
type mockBackupService struct {
createFunc func() (string, error)
createFunc func() (string, error)
availableSpaceFunc func() (int64, error)
}
func (m *mockBackupService) CreateBackup() (string, error) {
@@ -253,6 +267,14 @@ func (m *mockBackupService) RestoreBackup(filename string) error {
return fmt.Errorf("not implemented")
}
func (m *mockBackupService) GetAvailableSpace() (int64, error) {
if m.availableSpaceFunc != nil {
return m.availableSpaceFunc()
}
// Default: return 1GB available
return 1024 * 1024 * 1024, nil
}
// Test List handler
func TestCertificateHandler_List(t *testing.T) {
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
@@ -266,6 +288,8 @@ func TestCertificateHandler_List(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.GET("/api/certificates", h.List)
@@ -292,6 +316,7 @@ func TestCertificateHandler_Upload_MissingName(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -319,6 +344,7 @@ func TestCertificateHandler_Upload_MissingCertFile(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -349,6 +375,7 @@ func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
svc := services.NewCertificateService("/tmp", db)
h := NewCertificateHandler(svc, nil, nil)
r.POST("/api/certificates", h.Upload)
@@ -376,6 +403,7 @@ func TestCertificateHandler_Upload_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(mockAuthMiddleware())
// Create a mock CertificateService that returns a created certificate
// Create a temporary services.CertificateService with a temp dir and DB
+3 -3
View File
@@ -315,9 +315,9 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
logger.Log().WithField("caddy_data_dir", caddyDataDir).Info("Using Caddy data directory for certificates scan")
certService := services.NewCertificateService(caddyDataDir, db)
certHandler := handlers.NewCertificateHandler(certService, backupService, notificationService)
api.GET("/certificates", certHandler.List)
api.POST("/certificates", certHandler.Upload)
api.DELETE("/certificates/:id", certHandler.Delete)
protected.GET("/certificates", certHandler.List)
protected.POST("/certificates", certHandler.Upload)
protected.DELETE("/certificates/:id", certHandler.Delete)
// Initial Caddy Config Sync
go func() {
@@ -8,6 +8,7 @@ import (
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/Wikid82/charon/backend/internal/config"
@@ -267,3 +268,13 @@ func (s *BackupService) unzip(src, dest string) error {
}
return nil
}
// GetAvailableSpace returns the available disk space in bytes for the backup directory
func (s *BackupService) GetAvailableSpace() (int64, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(s.BackupDir, &stat); err != nil {
return 0, fmt.Errorf("failed to get disk space: %w", err)
}
// Available blocks * block size = available bytes
return int64(stat.Bavail) * int64(stat.Bsize), nil
}
@@ -441,6 +441,36 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) {
assert.Equal(t, gorm.ErrRecordNotFound, err)
})
t.Run("delete certificate in use returns ErrCertInUse", func(t *testing.T) {
// Create certificate
domain := "in-use.com"
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCert(t, domain, expiry)
cert, err := cs.UploadCertificate("In Use", string(certPEM), "FAKE KEY")
require.NoError(t, err)
// Create proxy host using this certificate
ph := models.ProxyHost{
UUID: "test-ph",
Name: "Test Host",
DomainNames: "in-use.com",
ForwardHost: "localhost",
ForwardPort: 8080,
CertificateID: &cert.ID,
}
require.NoError(t, db.Create(&ph).Error)
// Attempt to delete certificate - should fail with ErrCertInUse
err = cs.DeleteCertificate(cert.ID)
assert.Error(t, err)
assert.Equal(t, ErrCertInUse, err)
// Verify certificate still exists
var dbCert models.SSLCertificate
err = db.First(&dbCert, "id = ?", cert.ID).Error
assert.NoError(t, err)
})
t.Run("delete certificate when file already removed", func(t *testing.T) {
// Create and upload cert
domain := "to-delete.com"
@@ -741,6 +771,122 @@ func TestCertificateService_CertificateWithSANs(t *testing.T) {
})
}
func TestCertificateService_IsCertificateInUse(t *testing.T) {
tmpDir := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
cs := newTestCertificateService(tmpDir, db)
t.Run("certificate not in use", func(t *testing.T) {
// Create certificate without any proxy hosts
domain := "unused.com"
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCert(t, domain, expiry)
cert, err := cs.UploadCertificate("Unused", string(certPEM), "FAKE KEY")
require.NoError(t, err)
inUse, err := cs.IsCertificateInUse(cert.ID)
assert.NoError(t, err)
assert.False(t, inUse)
})
t.Run("certificate used by one proxy host", func(t *testing.T) {
// Create certificate
domain := "used.com"
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCert(t, domain, expiry)
cert, err := cs.UploadCertificate("Used", string(certPEM), "FAKE KEY")
require.NoError(t, err)
// Create proxy host using this certificate
ph := models.ProxyHost{
UUID: "ph-1",
Name: "Test Host 1",
DomainNames: "used.com",
ForwardHost: "localhost",
ForwardPort: 8080,
CertificateID: &cert.ID,
}
require.NoError(t, db.Create(&ph).Error)
inUse, err := cs.IsCertificateInUse(cert.ID)
assert.NoError(t, err)
assert.True(t, inUse)
})
t.Run("certificate used by multiple proxy hosts", func(t *testing.T) {
// Create certificate
domain := "shared.com"
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCert(t, domain, expiry)
cert, err := cs.UploadCertificate("Shared", string(certPEM), "FAKE KEY")
require.NoError(t, err)
// Create multiple proxy hosts using this certificate
for i := 1; i <= 3; i++ {
ph := models.ProxyHost{
UUID: fmt.Sprintf("ph-shared-%d", i),
Name: fmt.Sprintf("Test Host %d", i),
DomainNames: fmt.Sprintf("host%d.shared.com", i),
ForwardHost: "localhost",
ForwardPort: 8080 + i,
CertificateID: &cert.ID,
}
require.NoError(t, db.Create(&ph).Error)
}
inUse, err := cs.IsCertificateInUse(cert.ID)
assert.NoError(t, err)
assert.True(t, inUse)
})
t.Run("non-existent certificate", func(t *testing.T) {
inUse, err := cs.IsCertificateInUse(99999)
assert.NoError(t, err) // No error, just returns false
assert.False(t, inUse)
})
t.Run("certificate freed after proxy host deletion", func(t *testing.T) {
// Create certificate
domain := "freed.com"
expiry := time.Now().Add(24 * time.Hour)
certPEM := generateTestCert(t, domain, expiry)
cert, err := cs.UploadCertificate("Freed", string(certPEM), "FAKE KEY")
require.NoError(t, err)
// Create proxy host using this certificate
ph := models.ProxyHost{
UUID: "ph-freed",
Name: "Test Host Freed",
DomainNames: "freed.com",
ForwardHost: "localhost",
ForwardPort: 8080,
CertificateID: &cert.ID,
}
require.NoError(t, db.Create(&ph).Error)
// Verify in use
inUse, err := cs.IsCertificateInUse(cert.ID)
assert.NoError(t, err)
assert.True(t, inUse)
// Delete the proxy host
require.NoError(t, db.Delete(&ph).Error)
// Verify no longer in use
inUse, err = cs.IsCertificateInUse(cert.ID)
assert.NoError(t, err)
assert.False(t, inUse)
// Now deletion should succeed
err = cs.DeleteCertificate(cert.ID)
assert.NoError(t, err)
})
}
func TestCertificateService_CacheBehavior(t *testing.T) {
t.Run("cache returns consistent results", func(t *testing.T) {
tmpDir := t.TempDir()
+89
View File
@@ -187,6 +187,95 @@ Response 200: `{ "deleted": true }`
---
### SSL Certificates
#### List All Certificates
```http
GET /certificates
```
**Response 200:**
```json
[
{
"id": 1,
"uuid": "cert-uuid-123",
"name": "My Custom Cert",
"provider": "custom",
"domains": "example.com, www.example.com",
"expires_at": "2026-01-01T00:00:00Z",
"created_at": "2025-01-01T10:00:00Z"
}
]
```
#### Upload Certificate
```http
POST /certificates/upload
Content-Type: multipart/form-data
```
**Request Body:**
- `name` (required) - Certificate name
- `certificate_file` (required) - Certificate file (.crt or .pem)
- `key_file` (required) - Private key file (.key or .pem)
**Response 201:**
```json
{
"id": 1,
"uuid": "cert-uuid-123",
"name": "My Custom Cert",
"provider": "custom",
"domains": "example.com"
}
```
#### Delete Certificate
Delete a certificate. Requires that the certificate is not currently in use by any proxy hosts.
```http
DELETE /certificates/:id
```
**Parameters:**
- `id` (path) - Certificate ID (numeric)
**Response 200:**
```json
{
"message": "certificate deleted"
}
```
**Response 400:**
```json
{
"error": "invalid id"
}
```
**Response 409:**
```json
{
"error": "certificate is in use by one or more proxy hosts"
}
```
**Response 500:**
```json
{
"error": "failed to delete certificate"
}
```
**Note:** A backup is automatically created before deletion. The certificate files are removed from disk along with the database record.
---
### Proxy Hosts
#### List All Proxy Hosts
+22
View File
@@ -12,6 +12,28 @@ Here's everything Charon can do for you, explained simply.
**What you do:** Nothing. Charon gets free certificates from Let's Encrypt and renews them automatically.
### Smart Certificate Cleanup
**What it does:** When you delete websites, Charon asks if you want to delete unused certificates too.
**Why you care:** Custom and staging certificates can pile up over time. This helps you keep things tidy.
**How it works:**
- Delete a website → Charon checks if its certificate is used elsewhere
- If the certificate is custom or staging (not Let's Encrypt) and orphaned → you get a prompt
- Choose to keep or delete the certificate
- Default is "keep" (safe choice)
**When it prompts:**
- ✅ Custom certificates you uploaded
- ✅ Staging certificates (for testing)
- ❌ Let's Encrypt certificates (managed automatically)
**What you do:**
- See the prompt after clicking Delete on a proxy host
- Check the box if you want to delete the orphaned certificate
- Leave unchecked to keep the certificate (in case you need it later)
---
## \ud83d\udee1\ufe0f Security (Optional)
+699
View File
@@ -0,0 +1,699 @@
# Certificate Management Enhancement - Execution Plan
**Issue**: The Certificates page has no actions for deleting certificates, and proxy host deletion doesn't prompt about certificate cleanup.
**Date**: December 5, 2025
**Status**: Planning Complete - Ready for Implementation
---
## Overview
This plan implements two related features:
1. **Certificate Deletion Actions**: Add delete buttons to the Certificates page actions column for expired/unused certificates
2. **Proxy Host Deletion Certificate Prompt**: When deleting a proxy host, prompt user to confirm deletion of the associated certificate (default: No)
Both features prioritize user safety with confirmation dialogs, automatic backups, and sensible defaults.
---
## Architecture Analysis
### Current State
**Backend**:
- Certificate model: `backend/internal/models/ssl_certificate.go` - SSLCertificate with ID, UUID, Name, Provider, Domains, etc.
- ProxyHost model: `backend/internal/models/proxy_host.go` - Has `CertificateID *uint` (nullable foreign key) and `Certificate *SSLCertificate` relationship
- Certificate service: `backend/internal/services/certificate_service.go`
- Already has `DeleteCertificate(id uint) error` method
- Already has `IsCertificateInUse(id uint) (bool, error)` - checks if cert is linked to any ProxyHost
- Returns `ErrCertInUse` error if certificate is in use
- Certificate handler: `backend/internal/api/handlers/certificate_handler.go`
- Already has `Delete(c *gin.Context)` endpoint at `DELETE /api/v1/certificates/:id`
- Creates backup before deletion (if backupService available)
- Checks if certificate is in use and returns 409 Conflict if so
- Returns appropriate error messages
**Frontend**:
- CertificateList component: `frontend/src/components/CertificateList.tsx`
- Already checks if certificate is in use: `hosts.some(h => h.certificate_id === cert.id)`
- Already has delete button for custom and staging certificates
- Already shows appropriate confirmation messages
- Already creates backup before deletion
- ProxyHostForm: `frontend/src/components/ProxyHostForm.tsx`
- Certificate selector with dropdown showing available certificates
- No certificate deletion logic on proxy host deletion
- ProxyHosts page: `frontend/src/pages/ProxyHosts.tsx`
- Delete handler calls `deleteHost(uuid, deleteUptime?)`
- Currently prompts about uptime monitors but not certificates
**Key Relationships**:
- One certificate can be used by multiple proxy hosts (one-to-many)
- Proxy hosts can have no certificate (certificate_id is nullable)
- Backend prevents deletion of certificates in use (409 Conflict)
- Frontend already checks usage and blocks deletion
---
## Backend Requirements
### Current Implementation is COMPLETE ✅
The backend already has all required functionality:
1.**DELETE /api/v1/certificates/:id** endpoint exists
2. ✅ Certificate usage validation (`IsCertificateInUse`)
3. ✅ Backup creation before deletion
4. ✅ Proper error responses (400, 404, 409, 500)
5. ✅ Notification service integration
6. ✅ GORM relationship handling
**No backend changes required** - the API fully supports certificate deletion with proper validation.
### Proxy Host Deletion - No Changes Needed
The proxy host deletion endpoint (`DELETE /api/v1/proxy-hosts/:uuid`) already:
- Deletes the proxy host
- GORM cascade rules handle the relationship cleanup
- Does NOT delete the certificate (certificate is shared resource)
This is **correct behavior** - certificates should not be auto-deleted when a proxy host is removed, as they may be:
- Used by other proxy hosts
- Reusable for future proxy hosts
- Auto-managed by Let's Encrypt (shouldn't be manually deleted)
**Frontend will handle certificate cleanup prompting** - no backend API changes needed.
---
## Frontend Requirements
### 1. Certificate Actions Column (Already Working)
**Status**: ✅ **IMPLEMENTED** in `frontend/src/components/CertificateList.tsx`
The actions column already shows delete buttons for:
- Custom certificates (`cert.provider === 'custom'`)
- Staging certificates (`cert.issuer?.toLowerCase().includes('staging')`)
The delete logic already:
- Checks if certificate is in use by proxy hosts
- Shows appropriate confirmation messages
- Creates backup before deletion
- Handles errors properly
**Current implementation is correct and complete.**
### 2. Proxy Host Deletion Certificate Prompt (NEW FEATURE)
**File**: `frontend/src/pages/ProxyHosts.tsx`
**Location**: `handleDelete` function (lines ~119-162)
**Required Changes**:
1. **Update `handleDelete` function** to check for associated certificates:
```tsx
const handleDelete = async (uuid: string) => {
const host = hosts.find(h => h.uuid === uuid)
if (!host) return
if (!confirm('Are you sure you want to delete this proxy host?')) return
try {
// Check for uptime monitors (existing code)
let associatedMonitors: UptimeMonitor[] = []
// ... existing uptime monitor logic ...
// NEW: Check for associated certificate
let shouldDeleteCert = false
if (host.certificate_id && host.certificate) {
const cert = host.certificate
// Check if this is the ONLY proxy host using this certificate
const otherHostsUsingCert = hosts.filter(h =>
h.uuid !== uuid && h.certificate_id === host.certificate_id
).length
if (otherHostsUsingCert === 0) {
// This is the only host using the certificate
// Only prompt for custom/staging certs (not production Let's Encrypt)
if (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) {
shouldDeleteCert = confirm(
`This proxy host uses certificate "${cert.name || cert.domain}". ` +
`Do you want to delete the certificate as well?\n\n` +
`Click "Cancel" to keep the certificate (default).`
)
}
}
}
// Delete uptime monitors if confirmed (existing)
if (associatedMonitors.length > 0) {
const deleteUptime = confirm('...')
await deleteHost(uuid, deleteUptime)
} else {
await deleteHost(uuid)
}
// NEW: Delete certificate if user confirmed
if (shouldDeleteCert && host.certificate_id) {
try {
await deleteCertificate(host.certificate_id)
toast.success('Proxy host and certificate deleted')
} catch (err) {
// Host is already deleted, just log cert deletion failure
toast.error(`Proxy host deleted but failed to delete certificate: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete')
}
}
```
2. **Import required API function**:
```tsx
import { deleteCertificate } from '../api/certificates'
```
3. **UI/UX Considerations**:
- Show certificate prompt AFTER proxy host deletion confirmation
- Default is "No" (Cancel button) - safer option
- Only prompt for custom/staging certificates (not production Let's Encrypt)
- Only prompt if this is the ONLY host using the certificate
- Certificate deletion happens AFTER host deletion (host must be removed first to pass backend validation)
- Show appropriate toast messages for both actions
### 3. Bulk Proxy Host Deletion (Enhancement)
**File**: `frontend/src/pages/ProxyHosts.tsx`
**Location**: `handleBulkDelete` function (lines ~204-242)
**Required Changes** (similar pattern):
```tsx
const handleBulkDelete = async () => {
const hostUUIDs = Array.from(selectedHosts)
setIsCreatingBackup(true)
try {
// Create automatic backup (existing)
toast.loading('Creating backup before deletion...')
const backup = await createBackup()
toast.dismiss()
toast.success(`Backup created: ${backup.filename}`)
// NEW: Collect certificates to potentially delete
const certsToConsider: Set<number> = new Set()
hostUUIDs.forEach(uuid => {
const host = hosts.find(h => h.uuid === uuid)
if (host?.certificate_id && host.certificate) {
const cert = host.certificate
// Only consider custom/staging certs
if (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) {
// Check if this cert is ONLY used by hosts being deleted
const otherHosts = hosts.filter(h =>
h.certificate_id === host.certificate_id &&
!hostUUIDs.includes(h.uuid)
)
if (otherHosts.length === 0) {
certsToConsider.add(host.certificate_id)
}
}
}
})
// NEW: Prompt for certificate deletion if any are orphaned
let shouldDeleteCerts = false
if (certsToConsider.size > 0) {
shouldDeleteCerts = confirm(
`${certsToConsider.size} certificate(s) will no longer be used after deleting these hosts. ` +
`Do you want to delete the unused certificates as well?\n\n` +
`Click "Cancel" to keep the certificates (default).`
)
}
// Delete each host (existing)
let deleted = 0
let failed = 0
for (const uuid of hostUUIDs) {
try {
await deleteHost(uuid)
deleted++
} catch {
failed++
}
}
// NEW: Delete certificates if user confirmed
if (shouldDeleteCerts && certsToConsider.size > 0) {
let certsDeleted = 0
let certsFailed = 0
for (const certId of certsToConsider) {
try {
await deleteCertificate(certId)
certsDeleted++
} catch {
certsFailed++
}
}
if (certsFailed > 0) {
toast.error(`Deleted ${deleted} host(s) and ${certsDeleted} certificate(s), ${certsFailed} certificate(s) failed`)
} else if (certsDeleted > 0) {
toast.success(`Deleted ${deleted} host(s) and ${certsDeleted} certificate(s)`)
}
} else {
// No certs deleted (existing logic)
if (failed > 0) {
toast.error(`Deleted ${deleted} host(s), ${failed} failed`)
} else {
toast.success(`Successfully deleted ${deleted} host(s). Backup available for restore.`)
}
}
setSelectedHosts(new Set())
setShowBulkDeleteModal(false)
} catch (err) {
toast.dismiss()
toast.error('Failed to create backup. Deletion cancelled.')
} finally {
setIsCreatingBackup(false)
}
}
```
---
## Testing Strategy
### Backend Tests (Already Exist) ✅
Location: `backend/internal/api/handlers/certificate_handler_test.go`
Existing tests cover:
- ✅ Delete certificate in use (409 Conflict)
- ✅ Delete certificate not in use (success with backup)
- ✅ Delete invalid ID (400 Bad Request)
- ✅ Delete non-existent certificate (404 Not Found)
- ✅ Delete without backup service (still succeeds)
**No new backend tests required** - coverage is complete.
### Frontend Tests (Need Updates)
#### 1. CertificateList Component Tests ✅
Location: `frontend/src/components/__tests__/CertificateList.test.tsx`
Already has tests for:
- ✅ Delete custom certificate with confirmation
- ✅ Delete staging certificate
- ✅ Block deletion when certificate is in use
- ✅ Block deletion when certificate is active (valid/expiring)
**Current tests are sufficient.**
#### 2. ProxyHosts Component Tests (Need New Tests)
Location: `frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx`
**New tests required**:
```typescript
describe('ProxyHosts - Certificate Deletion Prompts', () => {
it('prompts to delete certificate when deleting proxy host with unique custom cert', async () => {
const cert = { id: 1, provider: 'custom', name: 'CustomCert', domain: 'test.com' }
const host = baseHost({
uuid: 'h1',
name: 'Host1',
certificate_id: 1,
certificate: cert
})
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([cert])
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Confirm proxy host deletion
.mockReturnValueOnce(true) // Confirm certificate deletion
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
const deleteBtn = screen.getByText('Delete')
await userEvent.click(deleteBtn)
await waitFor(() => {
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
})
confirmSpy.mockRestore()
})
it('does NOT prompt for certificate deletion when cert is shared by multiple hosts', async () => {
const cert = { id: 1, provider: 'custom', name: 'SharedCert' }
const host1 = baseHost({ uuid: 'h1', certificate_id: 1, certificate: cert })
const host2 = baseHost({ uuid: 'h2', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Only asked once (proxy host deletion)
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText(host1.name)).toBeTruthy())
const deleteBtn = screen.getAllByText('Delete')[0]
await userEvent.click(deleteBtn)
await waitFor(() => expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1'))
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
expect(confirmSpy).toHaveBeenCalledTimes(1) // Only proxy host confirmation
confirmSpy.mockRestore()
})
it('does NOT prompt for production Let\'s Encrypt certificates', async () => {
const cert = { id: 1, provider: 'letsencrypt', issuer: 'Let\'s Encrypt', name: 'LE Prod' }
const host = baseHost({ uuid: 'h1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Only proxy host deletion
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText(host.name)).toBeTruthy())
const deleteBtn = screen.getByText('Delete')
await userEvent.click(deleteBtn)
expect(confirmSpy).toHaveBeenCalledTimes(1) // No cert prompt
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
it('prompts for staging certificates', async () => {
const cert = {
id: 1,
provider: 'letsencrypt-staging',
issuer: 'Let\'s Encrypt Staging',
name: 'Staging Cert'
}
const host = baseHost({ uuid: 'h1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Proxy host deletion
.mockReturnValueOnce(false) // Decline certificate deletion (default)
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText(host.name)).toBeTruthy())
const deleteBtn = screen.getByText('Delete')
await userEvent.click(deleteBtn)
await waitFor(() => expect(confirmSpy).toHaveBeenCalledTimes(2))
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
it('handles certificate deletion failure gracefully', async () => {
const cert = { id: 1, provider: 'custom', name: 'CustomCert' }
const host = baseHost({ uuid: 'h1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
vi.mocked(certificatesApi.deleteCertificate).mockRejectedValue(
new Error('Certificate is still in use')
)
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Proxy host
.mockReturnValueOnce(true) // Certificate
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText(host.name)).toBeTruthy())
const deleteBtn = screen.getByText('Delete')
await userEvent.click(deleteBtn)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
expect.stringContaining('failed to delete certificate')
)
})
confirmSpy.mockRestore()
})
it('bulk delete prompts for orphaned certificates', async () => {
const cert = { id: 1, provider: 'custom', name: 'BulkCert' }
const host1 = baseHost({ uuid: 'h1', certificate_id: 1, certificate: cert })
const host2 = baseHost({ uuid: 'h2', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.db' })
const confirmSpy = vi.spyOn(window, 'confirm')
.mockReturnValueOnce(true) // Confirm bulk delete modal
.mockReturnValueOnce(true) // Confirm certificate deletion
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText(host1.name)).toBeTruthy())
// Select both hosts
const checkboxes = screen.getAllByRole('checkbox')
await userEvent.click(checkboxes[0]) // Select all
// Click bulk delete
const bulkDeleteBtn = screen.getByText('Delete')
await userEvent.click(bulkDeleteBtn)
// Confirm in modal
await userEvent.click(screen.getByText('Delete Permanently'))
await waitFor(() => {
expect(confirmSpy).toHaveBeenCalledTimes(2)
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
})
confirmSpy.mockRestore()
})
})
```
#### 3. Integration Tests
**Manual Testing Checklist**:
- [ ] Delete custom certificate from Certificates page
- [ ] Attempt to delete certificate in use (should show error)
- [ ] Delete proxy host with unique custom certificate (should prompt)
- [ ] Delete proxy host with shared certificate (should NOT prompt)
- [ ] Delete proxy host with production Let's Encrypt cert (should NOT prompt)
- [ ] Delete proxy host with staging certificate (should prompt)
- [ ] Decline certificate deletion (default) - only host deleted
- [ ] Accept certificate deletion - both deleted
- [ ] Bulk delete hosts with orphaned certificates
- [ ] Verify backups are created before deletions
- [ ] Check certificate deletion failure doesn't block host deletion
---
## Security Considerations
### Authorization
- ✅ All certificate endpoints protected by authentication middleware
- ✅ All proxy host endpoints protected by authentication middleware
- ✅ Only authenticated users can delete resources
### Validation
- ✅ Backend validates certificate not in use before deletion (409 Conflict)
- ✅ Backend validates certificate ID is numeric and exists (400/404)
- ✅ Frontend checks certificate usage before allowing deletion
- ✅ Frontend validates proxy host UUID before deletion
### Data Protection
- ✅ Automatic backup created before all deletions
- ✅ Soft deletes NOT used (certificates are fully removed)
- ✅ File system cleanup for Let's Encrypt certificates
- ✅ Database cascade rules properly configured
### User Safety
- ✅ Confirmation dialogs required for all deletions
- ✅ Certificate deletion default is "No" (safer)
- ✅ Clear messaging about what will be deleted
- ✅ Descriptive toast messages for success/failure
- ✅ Only prompt for custom/staging certs (not production)
- ✅ Only prompt when certificate is orphaned (no other hosts)
### Error Handling
- ✅ Graceful handling of certificate deletion failures
- ✅ Host deletion succeeds even if cert deletion fails
- ✅ Appropriate error messages shown to user
- ✅ Failed deletions don't block other operations
---
## Implementation Order
### Phase 1: Certificate Actions Column ✅
**Status**: COMPLETE - Already implemented correctly
No changes needed.
### Phase 2: Single Proxy Host Deletion Certificate Prompt
**Priority**: HIGH
**Estimated Time**: 2 hours
1. Update `frontend/src/pages/ProxyHosts.tsx`:
- Modify `handleDelete` function to check for certificates
- Add certificate deletion prompt logic
- Handle certificate deletion after host deletion
- Import `deleteCertificate` API function
2. Write unit tests in `frontend/src/pages/__tests__/ProxyHosts-coverage.test.tsx`:
- Test certificate prompt for unique custom cert
- Test no prompt for shared certificate
- Test no prompt for production Let's Encrypt
- Test prompt for staging certificates
- Test default "No" behavior
- Test certificate deletion failure handling
3. Manual testing:
- Test all scenarios in Testing Strategy checklist
- Verify toast messages are clear
- Verify backups are created
- Test error cases
### Phase 3: Bulk Proxy Host Deletion Certificate Prompt
**Priority**: MEDIUM
**Estimated Time**: 2 hours
1. Update `frontend/src/pages/ProxyHosts.tsx`:
- Modify `handleBulkDelete` function
- Add logic to identify orphaned certificates
- Add certificate deletion prompt
- Handle bulk certificate deletion
2. Write unit tests:
- Test bulk deletion with orphaned certificates
- Test bulk deletion with shared certificates
- Test mixed scenarios
3. Manual testing:
- Bulk delete scenarios
- Multiple certificate handling
- Error recovery
### Phase 4: Documentation & Polish
**Priority**: LOW
**Estimated Time**: 1 hour
1. Update `docs/features.md`:
- Document certificate deletion feature
- Document proxy host certificate cleanup
2. Update `docs/api.md` (if needed):
- Verify certificate deletion endpoint documented
3. Code review:
- Review all changes
- Ensure consistent error messages
- Verify test coverage
---
## Risk Assessment
### Low Risk ✅
- Backend API already exists and is well-tested
- Certificate deletion already works correctly
- Backup system already in place
- Frontend certificate list already handles deletion
### Medium Risk ⚠️
- User confusion about certificate deletion prompts
- **Mitigation**: Clear messaging, sensible defaults (No), only prompt for custom/staging
- Race conditions with shared certificates
- **Mitigation**: Check certificate usage at deletion time (backend validation)
- Certificate deletion failure after host deleted
- **Mitigation**: Graceful error handling, informative toast messages
### No Risk ❌
- Data loss: Backups created before all deletions
- Accidental deletion: Multiple confirmations required
- Production certs: Never prompted for deletion
---
## Success Criteria
### Must Have ✅
1. Certificate delete buttons visible in Certificates page actions column
2. Delete buttons only shown for custom and staging certificates
3. Certificate deletion blocked if in use by any proxy host
4. Automatic backup created before certificate deletion
5. Proxy host deletion prompts for certificate cleanup (default: No)
6. Certificate prompt only shown for custom/staging certs
7. Certificate prompt only shown when orphaned (no other hosts)
8. All operations have clear confirmation dialogs
9. All operations show appropriate toast messages
10. Backend validation prevents invalid deletions
### Nice to Have ✨
1. Show certificate usage count in Certificates table
2. Highlight orphaned certificates in the list
3. Batch certificate cleanup tool
4. Certificate expiry warnings before deletion
---
## Open Questions
1. ✅ Should production Let's Encrypt certificates ever be manually deletable?
- **Answer**: No, they are auto-managed by Caddy
2. ✅ Should certificate deletion be allowed if status is "valid" or "expiring"?
- **Answer**: Yes, if not in use (user may want to replace)
3. ✅ What happens if certificate deletion fails after host is deleted?
- **Answer**: Show error toast, certificate remains, user can delete later
4. ✅ Should bulk host deletion prompt for each certificate individually?
- **Answer**: No, single prompt for all orphaned certificates
---
## Notes
- Certificate deletion is a **shared resource operation** - multiple hosts can use the same certificate
- The backend correctly prevents deletion of in-use certificates (409 Conflict)
- The frontend already has all the UI components and logic needed
- Focus is on **adding prompts** to the proxy host deletion flow
- Default behavior is **conservative** (don't delete certificates) for safety
- Only custom and staging certificates are considered for cleanup
- Production Let's Encrypt certificates should never be manually deleted
---
## Definition of Done
- [ ] Certificate delete buttons visible and functional
- [ ] Proxy host deletion prompts for certificate cleanup
- [ ] All confirmation dialogs use appropriate defaults
- [ ] Unit tests written and passing
- [ ] Manual testing completed
- [ ] Documentation updated
- [ ] Code reviewed
- [ ] No console errors or warnings
- [ ] Pre-commit checks pass
- [ ] Feature tested in local Docker environment
---
**Plan Created**: December 5, 2025
**Plan Author**: Planning Agent (Architect)
**Ready for Implementation**: ✅ YES
+21
View File
@@ -129,6 +129,27 @@ Now only devices on `192.168.x.x` or `10.x.x.x` can access it. The public intern
---
## Certificate Management Security
**What it protects:** Certificate deletion is a destructive operation that requires proper authorization.
**How it works:**
- Certificates cannot be deleted while in use by proxy hosts (conflict error)
- Automatic backup is created before any certificate deletion
- Authentication required (when auth is implemented)
**Backup & Recovery:**
- Every certificate deletion triggers an automatic backup
- Find backups in the "Backups" page
- Restore from backup if you accidentally delete the wrong certificate
**Best Practice:**
- Review which proxy hosts use a certificate before deleting it
- When deleting proxy hosts, use the cleanup prompt to delete orphaned certificates
- Keep custom certificates you might reuse later
---
## Don't Lock Yourself Out!
**Problem:** If you turn on security and misconfigure it, you might block yourself.
@@ -0,0 +1,117 @@
import { AlertTriangle } from 'lucide-react'
interface CertificateCleanupDialogProps {
onConfirm: (deleteCerts: boolean) => void
onCancel: () => void
certificates: Array<{ id: number; name: string; domain: string }>
hostNames: string[]
isBulk?: boolean
}
export default function CertificateCleanupDialog({
onConfirm,
onCancel,
certificates,
hostNames,
isBulk = false
}: CertificateCleanupDialogProps) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const deleteCerts = formData.get('delete_certs') === 'on'
onConfirm(deleteCerts)
}
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onCancel}
>
<div
className="bg-dark-card border border-orange-900/50 rounded-lg p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<form onSubmit={handleSubmit}>
<div className="flex items-start gap-3 mb-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-orange-900/30 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-orange-400" />
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-white">
Delete {isBulk ? `${hostNames.length} Proxy Hosts` : 'Proxy Host'}?
</h2>
<p className="text-sm text-gray-400 mt-1">
{isBulk ? 'These hosts will be permanently deleted.' : 'This host will be permanently deleted.'}
</p>
</div>
</div>
{/* Host names */}
<div className="bg-gray-900/50 border border-gray-800 rounded-lg p-4 mb-4">
<p className="text-xs font-medium text-gray-400 uppercase mb-2">
{isBulk ? 'Hosts to be deleted:' : 'Host to be deleted:'}
</p>
<ul className="space-y-1 max-h-32 overflow-y-auto">
{hostNames.map((name, idx) => (
<li key={idx} className="text-sm text-white flex items-center gap-2">
<span className="text-red-400"></span>
<span className="font-medium">{name}</span>
</li>
))}
</ul>
</div>
{/* Certificate cleanup option */}
{certificates.length > 0 && (
<div className="bg-orange-900/10 border border-orange-800/50 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="delete_certs"
name="delete_certs"
className="mt-1 w-4 h-4 rounded border-gray-600 text-orange-500 focus:ring-orange-500 focus:ring-offset-0 bg-gray-700"
/>
<div className="flex-1">
<label htmlFor="delete_certs" className="text-sm text-orange-300 font-medium cursor-pointer">
Also delete {certificates.length === 1 ? 'orphaned certificate' : `${certificates.length} orphaned certificates`}
</label>
<p className="text-xs text-gray-400 mt-1">
{certificates.length === 1
? 'This custom/staging certificate will no longer be used by any hosts.'
: 'These custom/staging certificates will no longer be used by any hosts.'}
</p>
<ul className="mt-2 space-y-1">
{certificates.map((cert) => (
<li key={cert.id} className="text-xs text-gray-300 flex items-center gap-2">
<span className="text-orange-400"></span>
<span className="font-medium">{cert.name || cert.domain}</span>
<span className="text-gray-500">({cert.domain})</span>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Confirmation buttons */}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Delete
</button>
</div>
</form>
</div>
</div>
)
}
+195 -1
View File
@@ -7,6 +7,7 @@ import { useCertificates } from '../hooks/useCertificates'
import { useAccessLists } from '../hooks/useAccessLists'
import { getSettings } from '../api/settings'
import { createBackup } from '../api/backups'
import { deleteCertificate } from '../api/certificates'
import type { ProxyHost } from '../api/proxyHosts'
import compareHosts from '../utils/compareHosts'
import type { AccessList } from '../api/accessLists'
@@ -15,6 +16,7 @@ import { Switch } from '../components/ui/Switch'
import { toast } from 'react-hot-toast'
import { formatSettingLabel, settingHelpText, applyBulkSettingsToHosts } from '../utils/proxyHostsHelpers'
import { ConfigReloadOverlay } from '../components/LoadingStates'
import CertificateCleanupDialog from '../components/dialogs/CertificateCleanupDialog'
// Helper functions extracted for unit testing and reuse
// Helpers moved to ../utils/proxyHostsHelpers to keep component files component-only for fast refresh
@@ -35,6 +37,13 @@ export default function ProxyHosts() {
const [showBulkApplyModal, setShowBulkApplyModal] = useState(false)
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
const [isCreatingBackup, setIsCreatingBackup] = useState(false)
const [showCertCleanupDialog, setShowCertCleanupDialog] = useState(false)
const [certCleanupData, setCertCleanupData] = useState<{
hostUUIDs: string[]
hostNames: string[]
certificates: Array<{ id: number; name: string; domain: string }>
isBulk: boolean
} | null>(null)
const [selectedACLs, setSelectedACLs] = useState<Set<number>>(new Set())
const [bulkACLAction, setBulkACLAction] = useState<'apply' | 'remove'>('apply')
const [applyProgress, setApplyProgress] = useState<{ current: number; total: number } | null>(null)
@@ -139,6 +148,44 @@ export default function ProxyHosts() {
const host = hosts.find(h => h.uuid === uuid)
if (!host) return
// Check for orphaned certificates that would need cleanup
const orphanedCerts: Array<{ id: number; name: string; domain: string }> = []
if (host.certificate_id && host.certificate) {
const cert = host.certificate
// Check if this is the ONLY proxy host using this certificate
const otherHostsUsingCert = hosts.filter(h =>
h.uuid !== uuid && h.certificate_id === host.certificate_id
).length
if (otherHostsUsingCert === 0) {
// This is the only host using the certificate
// Only consider custom/staging certs (not production Let's Encrypt)
const isCustomOrStaging = cert.provider === 'custom' || cert.provider?.toLowerCase().includes('staging')
if (isCustomOrStaging) {
orphanedCerts.push({
id: cert.id!,
name: cert.name || '',
domain: cert.domains
})
}
}
}
// If there are orphaned certificates, show cleanup dialog
if (orphanedCerts.length > 0) {
setCertCleanupData({
hostUUIDs: [uuid],
hostNames: [host.name || host.domain_names],
certificates: orphanedCerts,
isBulk: false
})
setShowCertCleanupDialog(true)
return
}
// No orphaned certificates, proceed with standard deletion
if (!confirm('Are you sure you want to delete this proxy host?')) return
try {
@@ -162,6 +209,95 @@ export default function ProxyHosts() {
}
}
const handleCertCleanupConfirm = async (deleteCerts: boolean) => {
if (!certCleanupData) return
setShowCertCleanupDialog(false)
try {
// Delete hosts first
if (certCleanupData.isBulk) {
// Bulk deletion
let deleted = 0
let failed = 0
for (const uuid of certCleanupData.hostUUIDs) {
try {
await deleteHost(uuid)
deleted++
} catch {
failed++
}
}
// Delete certificates if user confirmed
if (deleteCerts && certCleanupData.certificates.length > 0) {
let certsDeleted = 0
let certsFailed = 0
for (const cert of certCleanupData.certificates) {
try {
await deleteCertificate(cert.id)
certsDeleted++
} catch {
certsFailed++
}
}
if (certsFailed > 0) {
toast.error(`Deleted ${deleted} host(s) and ${certsDeleted} certificate(s), ${certsFailed} certificate(s) failed`)
} else {
toast.success(`Deleted ${deleted} host(s) and ${certsDeleted} certificate(s)`)
}
} else {
if (failed > 0) {
toast.error(`Deleted ${deleted} host(s), ${failed} failed`)
} else {
toast.success(`Successfully deleted ${deleted} host(s)`)
}
}
} else {
// Single deletion
const uuid = certCleanupData.hostUUIDs[0]
const host = hosts.find(h => h.uuid === uuid)
// Check for uptime monitors
let associatedMonitors: UptimeMonitor[] = []
try {
const monitors = await getMonitors()
associatedMonitors = monitors.filter(m =>
host && (m.upstream_host === host.forward_host || (m.proxy_host_id && m.proxy_host_id === (host as unknown as { id?: number }).id))
)
} catch {
// ignore errors
}
if (associatedMonitors.length > 0) {
const deleteUptime = confirm('This proxy host has uptime monitors associated with it. Delete the monitors as well?')
await deleteHost(uuid, deleteUptime)
} else {
await deleteHost(uuid)
}
// Delete certificate if user confirmed
if (deleteCerts && certCleanupData.certificates.length > 0) {
try {
await deleteCertificate(certCleanupData.certificates[0].id)
toast.success('Proxy host and certificate deleted')
} catch (err) {
toast.error(`Proxy host deleted but failed to delete certificate: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete')
} finally {
setCertCleanupData(null)
setSelectedHosts(new Set())
setShowBulkDeleteModal(false)
}
}
const toggleHostSelection = (uuid: string) => {
setSelectedHosts(prev => {
const next = new Set(prev)
@@ -212,7 +348,51 @@ export default function ProxyHosts() {
toast.dismiss()
toast.success(`Backup created: ${backup.filename}`)
// Delete each host
// Collect certificates to potentially delete
const certsToConsider: Map<number, { id: number; name: string; domain: string }> = new Map()
hostUUIDs.forEach(uuid => {
const host = hosts.find(h => h.uuid === uuid)
if (host?.certificate_id && host.certificate) {
const cert = host.certificate
// Only consider custom/staging certs
const isCustomOrStaging = cert.provider === 'custom' || cert.provider?.toLowerCase().includes('staging')
if (isCustomOrStaging) {
// Check if this cert is ONLY used by hosts being deleted
const otherHosts = hosts.filter(h =>
h.certificate_id === host.certificate_id &&
!hostUUIDs.includes(h.uuid)
)
if (otherHosts.length === 0 && cert.id) {
certsToConsider.set(cert.id, {
id: cert.id,
name: cert.name || '',
domain: cert.domains
})
}
}
}
})
// If there are orphaned certificates, show cleanup dialog
if (certsToConsider.size > 0) {
const hostNames = hostUUIDs.map(uuid => {
const host = hosts.find(h => h.uuid === uuid)
return host?.name || host?.domain_names || 'Unnamed'
})
setCertCleanupData({
hostUUIDs,
hostNames,
certificates: Array.from(certsToConsider.values()),
isBulk: true
})
setShowCertCleanupDialog(true)
setIsCreatingBackup(false)
return
}
// No orphaned certificates, proceed with deletion
let deleted = 0
let failed = 0
@@ -908,6 +1088,20 @@ export default function ProxyHosts() {
</div>
</div>
)}
{/* Certificate Cleanup Dialog */}
{showCertCleanupDialog && certCleanupData && (
<CertificateCleanupDialog
onConfirm={handleCertCleanupConfirm}
onCancel={() => {
setShowCertCleanupDialog(false)
setCertCleanupData(null)
}}
certificates={certCleanupData.certificates}
hostNames={certCleanupData.hostNames}
isBulk={certCleanupData.isBulk}
/>
)}
</div>
</>
)
@@ -0,0 +1,442 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import type { ProxyHost, Certificate } from '../../api/proxyHosts'
import ProxyHosts from '../ProxyHosts'
import * as proxyHostsApi from '../../api/proxyHosts'
import * as certificatesApi from '../../api/certificates'
import * as accessListsApi from '../../api/accessLists'
import * as settingsApi from '../../api/settings'
import * as uptimeApi from '../../api/uptime'
import * as backupsApi from '../../api/backups'
import { createMockProxyHost } from '../../testUtils/createMockProxyHost'
vi.mock('react-hot-toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn()
}
}))
vi.mock('../../api/proxyHosts', () => ({
getProxyHosts: vi.fn(),
createProxyHost: vi.fn(),
updateProxyHost: vi.fn(),
deleteProxyHost: vi.fn(),
bulkUpdateACL: vi.fn(),
testProxyHostConnection: vi.fn(),
}))
vi.mock('../../api/certificates', () => ({
getCertificates: vi.fn(),
deleteCertificate: vi.fn(),
}))
vi.mock('../../api/accessLists', () => ({ accessListsApi: { list: vi.fn() } }))
vi.mock('../../api/settings', () => ({ getSettings: vi.fn() }))
vi.mock('../../api/backups', () => ({ createBackup: vi.fn() }))
vi.mock('../../api/uptime', () => ({ getMonitors: vi.fn() }))
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false }
}
})
const renderWithProviders = (ui: React.ReactNode) => {
const queryClient = createQueryClient()
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>
)
}
const baseHost = (overrides: Partial<ProxyHost> = {}) => createMockProxyHost(overrides)
describe('ProxyHosts - Certificate Cleanup Prompts', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(accessListsApi.accessListsApi.list).mockResolvedValue([])
vi.mocked(settingsApi.getSettings).mockResolvedValue({})
vi.mocked(uptimeApi.getMonitors).mockResolvedValue([])
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.db' } as any)
})
it('prompts to delete certificate when deleting proxy host with unique custom cert', async () => {
const cert: Certificate = {
id: 1,
uuid: 'cert-1',
provider: 'custom',
name: 'CustomCert',
domains: 'test.com',
expires_at: '2026-01-01T00:00:00Z'
}
const host = baseHost({
uuid: 'h1',
name: 'Host1',
certificate_id: 1,
certificate: cert
})
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
vi.mocked(certificatesApi.deleteCertificate).mockResolvedValue()
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
const deleteBtn = screen.getByRole('button', { name: /delete/i })
await userEvent.click(deleteBtn)
// Certificate cleanup dialog should appear
await waitFor(() => {
expect(screen.getByText('Delete Proxy Host?')).toBeTruthy()
expect(screen.getByText(/Also delete.*orphaned certificate/i)).toBeTruthy()
expect(screen.getByText('CustomCert')).toBeTruthy()
})
// Checkbox for certificate deletion (should be unchecked by default)
const checkbox = screen.getByRole('checkbox', { name: /Also delete/i }) as HTMLInputElement
expect(checkbox.checked).toBe(false)
// Check the checkbox to delete certificate
await userEvent.click(checkbox)
// Confirm deletion - get all Delete buttons and use the one in the dialog (last one)
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
await userEvent.click(deleteButtons[deleteButtons.length - 1])
await waitFor(() => {
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
})
})
it('does NOT prompt for certificate deletion when cert is shared by multiple hosts', async () => {
const cert: Certificate = {
id: 1,
uuid: 'cert-1',
provider: 'custom',
name: 'SharedCert',
domains: 'shared.com',
expires_at: '2026-01-01T00:00:00Z'
}
const host1 = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
const host2 = baseHost({ uuid: 'h2', name: 'Host2', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
const deleteButtons = screen.getAllByRole('button', { name: /delete/i })
await userEvent.click(deleteButtons[0])
// Should show standard confirmation, not certificate cleanup dialog
await waitFor(() => expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete this proxy host?'))
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
it('does NOT prompt for production Let\'s Encrypt certificates', async () => {
const cert: Certificate = {
id: 1,
uuid: 'cert-1',
provider: 'letsencrypt',
name: 'LE Prod',
domains: 'prod.com',
expires_at: '2026-01-01T00:00:00Z'
}
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
const deleteBtn = screen.getByRole('button', { name: /delete/i })
await userEvent.click(deleteBtn)
// Should show standard confirmation only
await waitFor(() => expect(confirmSpy).toHaveBeenCalledTimes(1))
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
it('prompts for staging certificates', async () => {
const cert: Certificate = {
id: 1,
uuid: 'cert-1',
provider: 'letsencrypt-staging',
name: 'Staging Cert',
domains: 'staging.com',
expires_at: '2026-01-01T00:00:00Z'
}
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
const deleteBtn = screen.getByRole('button', { name: /delete/i })
await userEvent.click(deleteBtn)
// Certificate cleanup dialog should appear
await waitFor(() => {
expect(screen.getByText('Delete Proxy Host?')).toBeTruthy()
expect(screen.getByText(/Also delete.*orphaned certificate/i)).toBeTruthy()
})
// Decline certificate deletion (click Delete without checking the box)
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
await userEvent.click(deleteButtons[deleteButtons.length - 1])
await waitFor(() => {
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
})
})
it('handles certificate deletion failure gracefully', async () => {
const cert: Certificate = {
id: 1,
uuid: 'cert-1',
provider: 'custom',
name: 'CustomCert',
domains: 'custom.com',
expires_at: '2026-01-01T00:00:00Z'
}
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
vi.mocked(certificatesApi.deleteCertificate).mockRejectedValue(
new Error('Certificate is still in use')
)
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
const deleteBtn = screen.getByRole('button', { name: /delete/i })
await userEvent.click(deleteBtn)
await waitFor(() => expect(screen.getByText('Delete Proxy Host?')).toBeTruthy())
// Check the certificate deletion checkbox
const checkbox = screen.getByRole('checkbox', { name: /Also delete/i })
await userEvent.click(checkbox)
// Confirm deletion
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
await userEvent.click(deleteButtons[deleteButtons.length - 1])
await waitFor(() => {
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
})
// Toast should show error about certificate but host was deleted
const toast = await import('react-hot-toast')
await waitFor(() => {
expect(toast.toast.error).toHaveBeenCalledWith(
expect.stringContaining('failed to delete certificate')
)
})
})
it('bulk delete prompts for orphaned certificates', async () => {
const cert: Certificate = {
id: 1,
uuid: 'cert-1',
provider: 'custom',
name: 'BulkCert',
domains: 'bulk.com',
expires_at: '2026-01-01T00:00:00Z'
}
const host1 = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
const host2 = baseHost({ uuid: 'h2', name: 'Host2', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
vi.mocked(certificatesApi.deleteCertificate).mockResolvedValue()
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
// Select all hosts
const selectAllCheckbox = screen.getAllByRole('checkbox')[0]
await userEvent.click(selectAllCheckbox)
// Click bulk delete button (the one with Trash icon in toolbar)
const bulkDeleteButtons = screen.getAllByRole('button', { name: /delete/i })
await userEvent.click(bulkDeleteButtons[0]) // First is the bulk delete button in the toolbar
// Confirm in bulk delete modal
await waitFor(() => expect(screen.getByText(/Delete 2 Proxy Hosts/)).toBeTruthy())
const deletePermBtn = screen.getByRole('button', { name: /Delete Permanently/i })
await userEvent.click(deletePermBtn)
// Should show certificate cleanup dialog
await waitFor(() => {
expect(screen.getByText(/Also delete.*orphaned certificate/i)).toBeTruthy()
expect(screen.getByText('BulkCert')).toBeTruthy()
})
// Check the certificate deletion checkbox
const certCheckbox = screen.getByRole('checkbox', { name: /Also delete/i })
await userEvent.click(certCheckbox)
// Confirm
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
await userEvent.click(deleteButtons[deleteButtons.length - 1])
await waitFor(() => {
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h2')
expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1)
})
})
it('bulk delete does NOT prompt when certificate is still used by other hosts', async () => {
const cert: Certificate = {
id: 1,
uuid: 'cert-1',
provider: 'custom',
name: 'SharedCert',
domains: 'shared.com',
expires_at: '2026-01-01T00:00:00Z'
}
const host1 = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
const host2 = baseHost({ uuid: 'h2', name: 'Host2', certificate_id: 1, certificate: cert })
const host3 = baseHost({ uuid: 'h3', name: 'Host3', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host1, host2, host3])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
// Select only host1 and host2 (host3 still uses the cert)
const host1Row = screen.getByText('Host1').closest('tr') as HTMLTableRowElement
const host2Row = screen.getByText('Host2').closest('tr') as HTMLTableRowElement
const host1Checkbox = within(host1Row).getByRole('checkbox', { name: /Select Host1/ })
const host2Checkbox = within(host2Row).getByRole('checkbox', { name: /Select Host2/ })
await userEvent.click(host1Checkbox)
await userEvent.click(host2Checkbox)
// Wait for bulk operations to be available
await waitFor(() => expect(screen.getByText('Bulk Apply')).toBeTruthy())
// Click bulk delete
const bulkDeleteButtons = screen.getAllByRole('button', { name: /delete/i })
await userEvent.click(bulkDeleteButtons[0]) // First is the bulk delete button in the toolbar
// Confirm in modal
await waitFor(() => expect(screen.getByText(/Delete 2 Proxy Hosts/)).toBeTruthy())
const deletePermBtn = screen.getByRole('button', { name: /Delete Permanently/i })
await userEvent.click(deletePermBtn)
// Should NOT show certificate cleanup dialog (host3 still uses it)
await waitFor(() => {
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h2')
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
})
})
it('allows cancelling certificate cleanup dialog', async () => {
const cert: Certificate = {
id: 1,
uuid: 'cert-1',
provider: 'custom',
name: 'CustomCert',
domains: 'test.com',
expires_at: '2026-01-01T00:00:00Z'
}
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
const deleteBtn = screen.getByRole('button', { name: /delete/i })
await userEvent.click(deleteBtn)
// Certificate cleanup dialog appears
await waitFor(() => expect(screen.getByText('Delete Proxy Host?')).toBeTruthy())
// Click Cancel
const cancelBtn = screen.getByRole('button', { name: 'Cancel' })
await userEvent.click(cancelBtn)
// Dialog should close, nothing deleted
await waitFor(() => {
expect(screen.queryByText('Delete Proxy Host?')).toBeFalsy()
expect(proxyHostsApi.deleteProxyHost).not.toHaveBeenCalled()
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
})
})
it('default state is unchecked for certificate deletion (conservative)', async () => {
const cert: Certificate = {
id: 1,
uuid: 'cert-1',
provider: 'custom',
name: 'CustomCert',
domains: 'test.com',
expires_at: '2026-01-01T00:00:00Z'
}
const host = baseHost({ uuid: 'h1', name: 'Host1', certificate_id: 1, certificate: cert })
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([host])
vi.mocked(certificatesApi.getCertificates).mockResolvedValue([])
vi.mocked(proxyHostsApi.deleteProxyHost).mockResolvedValue()
renderWithProviders(<ProxyHosts />)
await waitFor(() => expect(screen.getByText('Host1')).toBeTruthy())
const deleteBtn = screen.getByRole('button', { name: /delete/i })
await userEvent.click(deleteBtn)
await waitFor(() => expect(screen.getByText('Delete Proxy Host?')).toBeTruthy())
// Checkbox should be unchecked by default
const checkbox = screen.getByRole('checkbox', { name: /Also delete/i }) as HTMLInputElement
expect(checkbox.checked).toBe(false)
// Confirm deletion without checking the box
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
await userEvent.click(deleteButtons[deleteButtons.length - 1])
await waitFor(() => {
expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1')
expect(certificatesApi.deleteCertificate).not.toHaveBeenCalled()
})
})
})