Files
Charon/backend/internal/services/certificate_service.go
T
2025-11-24 18:22:01 +00:00

276 lines
8.0 KiB
Go

package services
import (
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
)
// 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"
Provider string `json:"provider"` // "letsencrypt", "custom"
}
// CertificateService manages certificate retrieval and parsing.
type CertificateService struct {
dataDir string
db *gorm.DB
}
// NewCertificateService creates a new certificate service.
func NewCertificateService(dataDir string, db *gorm.DB) *CertificateService {
return &CertificateService{
dataDir: dataDir,
db: db,
}
}
// ListCertificates returns both auto-generated and custom certificates.
func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) {
// First, scan Caddy data directory for auto-generated certificates and persist them.
certRoot := filepath.Join(s.dataDir, "certificates")
log.Printf("CertificateService: scanning cert directory: %s\n", certRoot)
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 {
log.Printf("CertificateService: walk error for %s: %v\n", path, err)
return nil
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") {
log.Printf("CertificateService: found cert file: %s\n", path)
certData, err := os.ReadFile(path)
if err != nil {
log.Printf("CertificateService: failed to read cert file %s: %v\n", path, err)
return nil
}
block, _ := pem.Decode(certData)
if block == nil {
log.Printf("CertificateService: pem decode failed for %s\n", path)
return nil
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
log.Printf("CertificateService: failed to parse cert %s: %v\n", path, err)
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
// Upsert into DB for provider 'letsencrypt'
var existing models.SSLCertificate
res := s.db.Where("provider = ? AND domains = ?", "letsencrypt", 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: "letsencrypt",
Domains: domain,
Certificate: string(certData),
PrivateKey: "",
ExpiresAt: &expiresAt,
AutoRenew: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.db.Create(&newCert).Error; err != nil {
log.Printf("CertificateService: failed to create DB cert for %s: %v\n", domain, err)
}
} else {
log.Printf("CertificateService: db error querying cert %s: %v\n", domain, res.Error)
}
} else {
// Update expiry/certificate content if changed
updated := false
existing.ExpiresAt = &expiresAt
if existing.Certificate != string(certData) {
existing.Certificate = string(certData)
updated = true
}
if updated {
existing.UpdatedAt = time.Now()
if err := s.db.Save(&existing).Error; err != nil {
log.Printf("CertificateService: failed to update DB cert for %s: %v\n", domain, err)
}
} else {
// still update ExpiresAt if needed
if err := s.db.Model(&existing).Update("expires_at", &expiresAt).Error; err != nil {
log.Printf("CertificateService: failed to update expiry for %s: %v\n", domain, err)
}
}
}
}
return nil
})
} else {
if os.IsNotExist(err) {
log.Printf("CertificateService: cert directory does not exist: %s\n", certRoot)
} else {
log.Printf("CertificateService: failed to stat cert directory: %v\n", err)
}
}
// Delete stale DB entries for provider 'letsencrypt' not found on disk
var acmeCerts []models.SSLCertificate
if err := s.db.Where("provider = ?", "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 {
log.Printf("CertificateService: failed to delete stale cert %s: %v\n", c.Domains, err)
} else {
log.Printf("CertificateService: removed stale DB cert for %s\n", c.Domains)
}
}
}
}
// Finally, fetch all certificates from DB to build the response (includes custom and persisted ACME)
certs := []CertificateInfo{}
var dbCerts []models.SSLCertificate
if err := s.db.Find(&dbCerts).Error; err != nil {
return nil, fmt.Errorf("failed to fetch certs from DB: %w", err)
}
for _, c := range dbCerts {
status := "valid"
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
}
certs = append(certs, CertificateInfo{
ID: c.ID,
UUID: c.UUID,
Name: c.Name,
Domain: c.Domains,
Issuer: c.Provider,
ExpiresAt: expires,
Status: status,
Provider: c.Provider,
})
}
return certs, nil
}
// 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
}
return sslCert, nil
}
// DeleteCertificate removes a certificate.
func (s *CertificateService) DeleteCertificate(id uint) error {
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
log.Printf("CertificateService: deleting ACME cert file %s", path)
if err := os.Remove(path); err != nil {
log.Printf("CertificateService: failed to delete cert file: %v", err)
}
// 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
})
}
return s.db.Delete(&models.SSLCertificate{}, "id = ?", id).Error
}