Files
Charon/backend/internal/services/certificate_service.go
GitHub Actions 336000ca5b feat: Add validation and error handling for notification templates and uptime handlers
- Implement tests for invalid JSON input in notification template creation, update, and preview endpoints.
- Enhance uptime handler tests to cover sync success and error scenarios for delete and list operations.
- Update routes to include backup service in certificate handler initialization.
- Introduce certificate usage check before deletion in the certificate service, preventing deletion of certificates in use.
- Update certificate service tests to validate new behavior regarding certificate deletion.
- Add new tests for security service to verify break glass token generation and validation.
- Enhance frontend certificate list component to prevent deletion of certificates in use and ensure proper backup creation.
- Create unit tests for the CertificateList component to validate deletion logic and error handling.
2025-12-03 04:55:29 +00:00

448 lines
14 KiB
Go

package services
import (
"crypto/x509"
"encoding/pem"
"fmt"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/util"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
// ErrCertInUse is returned when a certificate is linked to one or more proxy hosts.
var ErrCertInUse = fmt.Errorf("certificate is in use by one or more proxy hosts")
// CertificateInfo represents parsed certificate details.
type CertificateInfo struct {
ID uint `json:"id,omitempty"`
UUID string `json:"uuid,omitempty"`
Name string `json:"name,omitempty"`
Domain string `json:"domain"`
Issuer string `json:"issuer"`
ExpiresAt time.Time `json:"expires_at"`
Status string `json:"status"` // "valid", "expiring", "expired", "untrusted"
Provider string `json:"provider"` // "letsencrypt", "letsencrypt-staging", "custom"
}
// CertificateService manages certificate retrieval and parsing.
type CertificateService struct {
dataDir string
db *gorm.DB
cache []CertificateInfo
cacheMu sync.RWMutex
lastScan time.Time
scanTTL time.Duration
initialized bool
}
// NewCertificateService creates a new certificate service.
func NewCertificateService(dataDir string, db *gorm.DB) *CertificateService {
svc := &CertificateService{
dataDir: dataDir,
db: db,
scanTTL: 5 * time.Minute, // Only rescan disk every 5 minutes
}
// Perform initial scan in background
go func() {
if err := svc.SyncFromDisk(); err != nil {
logger.Log().WithError(err).Error("CertificateService: initial sync failed")
}
}()
return svc
}
// SyncFromDisk scans the certificate directory and syncs with database.
// This is called on startup and can be triggered manually for refresh.
func (s *CertificateService) SyncFromDisk() error {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
certRoot := filepath.Join(s.dataDir, "certificates")
logger.Log().WithField("certRoot", util.SanitizeForLog(certRoot)).Info("CertificateService: scanning cert directory")
foundDomains := map[string]struct{}{}
// If the cert root does not exist, skip scanning but still return DB entries below
if _, err := os.Stat(certRoot); err == nil {
_ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
logger.Log().WithField("path", util.SanitizeForLog(path)).WithError(err).Error("CertificateService: walk error")
return nil
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") {
certData, err := os.ReadFile(path)
if err != nil {
logger.Log().WithField("path", util.SanitizeForLog(path)).WithError(err).Error("CertificateService: failed to read cert file")
return nil
}
block, _ := pem.Decode(certData)
if block == nil {
// Silently skip invalid PEM files
return nil
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
logger.Log().WithField("path", util.SanitizeForLog(path)).WithError(err).Error("CertificateService: failed to parse cert")
return nil
}
domain := cert.Subject.CommonName
if domain == "" && len(cert.DNSNames) > 0 {
domain = cert.DNSNames[0]
}
if domain == "" {
return nil
}
foundDomains[domain] = struct{}{}
// Determine expiry
expiresAt := cert.NotAfter
// Detect if this is a staging certificate by checking the path
// Staging certs are in acme-staging-v02.api.letsencrypt.org-directory
provider := "letsencrypt"
if strings.Contains(path, "acme-staging") {
provider = "letsencrypt-staging"
}
// Upsert into DB
var existing models.SSLCertificate
res := s.db.Where("domains = ?", domain).First(&existing)
if res.Error != nil {
if res.Error == gorm.ErrRecordNotFound {
// Create new record
now := time.Now()
newCert := models.SSLCertificate{
UUID: uuid.New().String(),
Name: domain,
Provider: provider,
Domains: domain,
Certificate: string(certData),
PrivateKey: "",
ExpiresAt: &expiresAt,
AutoRenew: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.db.Create(&newCert).Error; err != nil {
logger.Log().WithField("domain", util.SanitizeForLog(domain)).WithError(err).Error("CertificateService: failed to create DB cert")
}
} else {
logger.Log().WithField("domain", util.SanitizeForLog(domain)).WithError(res.Error).Error("CertificateService: db error querying cert")
}
} else {
// Update expiry/certificate content and provider if changed
// But only upgrade staging->production, never downgrade production->staging
updated := false
existing.ExpiresAt = &expiresAt
// Determine if we should update the cert
// Production certs always win over staging certs
isExistingStaging := strings.Contains(existing.Provider, "staging")
isNewStaging := strings.Contains(provider, "staging")
shouldUpdateCert := false
if isExistingStaging && !isNewStaging {
// Upgrade from staging to production - always update
shouldUpdateCert = true
} else if !isExistingStaging && isNewStaging {
// Don't downgrade from production to staging - skip
} else if existing.Certificate != string(certData) {
// Same type but different content - update
shouldUpdateCert = true
}
if shouldUpdateCert {
existing.Certificate = string(certData)
existing.Provider = provider
updated = true
}
if updated {
existing.UpdatedAt = time.Now()
if err := s.db.Save(&existing).Error; err != nil {
logger.Log().WithField("domain", util.SanitizeForLog(domain)).WithError(err).Error("CertificateService: failed to update DB cert")
}
} else {
// still update ExpiresAt if needed
if err := s.db.Model(&existing).Update("expires_at", &expiresAt).Error; err != nil {
logger.Log().WithField("domain", util.SanitizeForLog(domain)).WithError(err).Error("CertificateService: failed to update expiry")
}
}
}
}
return nil
})
} else {
if os.IsNotExist(err) {
logger.Log().WithField("certRoot", certRoot).Info("CertificateService: cert directory does not exist")
} else {
logger.Log().WithError(err).Error("CertificateService: failed to stat cert directory")
}
}
// Delete stale DB entries for ACME certs not found on disk
var acmeCerts []models.SSLCertificate
if err := s.db.Where("provider LIKE ?", "letsencrypt%").Find(&acmeCerts).Error; err == nil {
for _, c := range acmeCerts {
if _, ok := foundDomains[c.Domains]; !ok {
// remove stale record
if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", c.ID).Error; err != nil {
logger.Log().WithField("domain", util.SanitizeForLog(c.Domains)).WithError(err).Error("CertificateService: failed to delete stale cert")
} else {
logger.Log().WithField("domain", util.SanitizeForLog(c.Domains)).Info("CertificateService: removed stale DB cert")
}
}
}
}
// Update cache from DB
if err := s.refreshCacheFromDB(); err != nil {
return fmt.Errorf("failed to refresh cache: %w", err)
}
s.lastScan = time.Now()
s.initialized = true
logger.Log().WithField("count", len(s.cache)).Info("CertificateService: disk sync complete")
return nil
}
// refreshCacheFromDB updates the in-memory cache from the database.
// Must be called with cacheMu held.
func (s *CertificateService) refreshCacheFromDB() error {
var dbCerts []models.SSLCertificate
if err := s.db.Find(&dbCerts).Error; err != nil {
return fmt.Errorf("failed to fetch certs from DB: %w", err)
}
// Build a map of domain -> proxy host name for quick lookup
var proxyHosts []models.ProxyHost
s.db.Find(&proxyHosts)
domainToName := make(map[string]string)
for _, ph := range proxyHosts {
if ph.Name == "" {
continue
}
// Handle comma-separated domains
domains := strings.Split(ph.DomainNames, ",")
for _, d := range domains {
d = strings.TrimSpace(strings.ToLower(d))
if d != "" {
domainToName[d] = ph.Name
}
}
}
certs := make([]CertificateInfo, 0, len(dbCerts))
for _, c := range dbCerts {
status := "valid"
// Staging certificates are untrusted by browsers
if strings.Contains(c.Provider, "staging") {
status = "untrusted"
} else if c.ExpiresAt != nil {
if time.Now().After(*c.ExpiresAt) {
status = "expired"
} else if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) {
status = "expiring"
}
}
expires := time.Time{}
if c.ExpiresAt != nil {
expires = *c.ExpiresAt
}
// Try to get name from proxy host, fall back to cert name or domain
name := c.Name
// Check all domains in the cert against proxy hosts
certDomains := strings.Split(c.Domains, ",")
for _, d := range certDomains {
d = strings.TrimSpace(strings.ToLower(d))
if phName, ok := domainToName[d]; ok {
name = phName
break
}
}
certs = append(certs, CertificateInfo{
ID: c.ID,
UUID: c.UUID,
Name: name,
Domain: c.Domains,
Issuer: c.Provider,
ExpiresAt: expires,
Status: status,
Provider: c.Provider,
})
}
s.cache = certs
return nil
}
// ListCertificates returns cached certificate info.
// Fast path: returns from cache if available.
// Triggers background rescan if cache is stale.
func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) {
s.cacheMu.RLock()
if s.initialized && time.Since(s.lastScan) < s.scanTTL {
// Cache is fresh, return it
result := make([]CertificateInfo, len(s.cache))
copy(result, s.cache)
s.cacheMu.RUnlock()
return result, nil
}
s.cacheMu.RUnlock()
// Cache is stale or not initialized - need to refresh
// If not initialized, do a blocking sync
if !s.initialized {
if err := s.SyncFromDisk(); err != nil {
// Fall back to DB query
s.cacheMu.Lock()
err := s.refreshCacheFromDB()
s.cacheMu.Unlock()
if err != nil {
return nil, err
}
}
} else {
// Trigger background rescan for stale cache
go func() {
if err := s.SyncFromDisk(); err != nil {
logger.Log().WithError(err).Error("CertificateService: background sync failed")
}
}()
}
// Return current cache (may be slightly stale)
s.cacheMu.RLock()
result := make([]CertificateInfo, len(s.cache))
copy(result, s.cache)
s.cacheMu.RUnlock()
return result, nil
}
// InvalidateCache clears the cache, forcing a blocking resync on next ListCertificates call.
func (s *CertificateService) InvalidateCache() {
s.cacheMu.Lock()
s.lastScan = time.Time{}
s.initialized = false // Force blocking resync
s.cache = nil
s.cacheMu.Unlock()
}
// UploadCertificate saves a new custom certificate.
func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM string) (*models.SSLCertificate, error) {
// Validate PEM
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return nil, fmt.Errorf("invalid certificate PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
// Create DB entry
sslCert := &models.SSLCertificate{
UUID: uuid.New().String(),
Name: name,
Provider: "custom",
Domains: cert.Subject.CommonName, // Or SANs
Certificate: certPEM,
PrivateKey: keyPEM,
ExpiresAt: &cert.NotAfter,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Handle SANs if present
if len(cert.DNSNames) > 0 {
sslCert.Domains = strings.Join(cert.DNSNames, ",")
}
if err := s.db.Create(sslCert).Error; err != nil {
return nil, err
}
// Invalidate cache so the new cert appears immediately
s.InvalidateCache()
return sslCert, nil
}
// IsCertificateInUse checks if a certificate is referenced by any proxy host.
func (s *CertificateService) IsCertificateInUse(id uint) (bool, error) {
var count int64
if err := s.db.Model(&models.ProxyHost{}).Where("certificate_id = ?", id).Count(&count).Error; err != nil {
return false, fmt.Errorf("check certificate linkage: %w", err)
}
return count > 0, nil
}
// DeleteCertificate removes a certificate.
func (s *CertificateService) DeleteCertificate(id uint) error {
// Prevent deletion if the certificate is referenced by any proxy host
inUse, err := s.IsCertificateInUse(id)
if err != nil {
return err
}
if inUse {
return ErrCertInUse
}
var cert models.SSLCertificate
if err := s.db.First(&cert, id).Error; err != nil {
return err
}
if cert.Provider == "letsencrypt" {
// Best-effort file deletion
certRoot := filepath.Join(s.dataDir, "certificates")
_ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") {
if info.Name() == cert.Domains+".crt" {
// Found it
logger.Log().WithField("path", path).Info("CertificateService: deleting ACME cert file")
if err := os.Remove(path); err != nil {
logger.Log().WithError(err).Error("CertificateService: failed to delete cert file")
}
// Try to delete key as well
keyPath := strings.TrimSuffix(path, ".crt") + ".key"
if _, err := os.Stat(keyPath); err == nil {
os.Remove(keyPath)
}
// Also try to delete the json meta file
jsonPath := strings.TrimSuffix(path, ".crt") + ".json"
if _, err := os.Stat(jsonPath); err == nil {
os.Remove(jsonPath)
}
}
}
return nil
})
}
if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", id).Error; err != nil {
return err
}
// Invalidate cache so the deleted cert disappears immediately
s.InvalidateCache()
return nil
}