1047 lines
32 KiB
Go
1047 lines
32 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
crand "crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/crypto"
|
|
"github.com/Wikid82/charon/backend/internal/logger"
|
|
"github.com/Wikid82/charon/backend/internal/util"
|
|
|
|
"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")
|
|
|
|
// ErrCertNotFound is returned when a certificate cannot be found by UUID.
|
|
var ErrCertNotFound = fmt.Errorf("certificate not found")
|
|
|
|
// CertificateInfo represents parsed certificate details for list responses.
|
|
type CertificateInfo struct {
|
|
UUID string `json:"uuid"`
|
|
Name string `json:"name,omitempty"`
|
|
CommonName string `json:"common_name,omitempty"`
|
|
Domains string `json:"domains"`
|
|
Issuer string `json:"issuer"`
|
|
IssuerOrg string `json:"issuer_org,omitempty"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
SerialNumber string `json:"serial_number,omitempty"`
|
|
KeyType string `json:"key_type,omitempty"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
NotBefore time.Time `json:"not_before,omitempty"`
|
|
Status string `json:"status"`
|
|
Provider string `json:"provider"`
|
|
ChainDepth int `json:"chain_depth,omitempty"`
|
|
HasKey bool `json:"has_key"`
|
|
InUse bool `json:"in_use"`
|
|
}
|
|
|
|
// AssignedHostInfo represents a proxy host assigned to a certificate.
|
|
type AssignedHostInfo struct {
|
|
UUID string `json:"uuid"`
|
|
Name string `json:"name"`
|
|
DomainNames string `json:"domain_names"`
|
|
}
|
|
|
|
// ChainEntry represents a single certificate in the chain.
|
|
type ChainEntry struct {
|
|
Subject string `json:"subject"`
|
|
Issuer string `json:"issuer"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
}
|
|
|
|
// CertificateDetail contains full certificate metadata for detail responses.
|
|
type CertificateDetail struct {
|
|
UUID string `json:"uuid"`
|
|
Name string `json:"name,omitempty"`
|
|
CommonName string `json:"common_name,omitempty"`
|
|
Domains string `json:"domains"`
|
|
Issuer string `json:"issuer"`
|
|
IssuerOrg string `json:"issuer_org,omitempty"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
SerialNumber string `json:"serial_number,omitempty"`
|
|
KeyType string `json:"key_type,omitempty"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
NotBefore time.Time `json:"not_before,omitempty"`
|
|
Status string `json:"status"`
|
|
Provider string `json:"provider"`
|
|
ChainDepth int `json:"chain_depth,omitempty"`
|
|
HasKey bool `json:"has_key"`
|
|
InUse bool `json:"in_use"`
|
|
AssignedHosts []AssignedHostInfo `json:"assigned_hosts"`
|
|
Chain []ChainEntry `json:"chain"`
|
|
AutoRenew bool `json:"auto_renew"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// CertificateService manages certificate retrieval and parsing.
|
|
type CertificateService struct {
|
|
dataDir string
|
|
db *gorm.DB
|
|
encSvc *crypto.EncryptionService
|
|
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, encSvc *crypto.EncryptionService) *CertificateService {
|
|
svc := &CertificateService{
|
|
dataDir: dataDir,
|
|
db: db,
|
|
encSvc: encSvc,
|
|
scanTTL: 5 * time.Minute,
|
|
}
|
|
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") {
|
|
// #nosec G304 -- path is controlled by filepath.Walk starting from certRoot
|
|
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
|
|
|
|
switch {
|
|
case isExistingStaging && !isNewStaging:
|
|
// Upgrade from staging to production - always update
|
|
shouldUpdateCert = true
|
|
case !isExistingStaging && isNewStaging:
|
|
// Don't downgrade from production to staging - skip
|
|
case 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 set of certificate IDs that are in use
|
|
certInUse := make(map[uint]bool)
|
|
var proxyHosts []models.ProxyHost
|
|
s.db.Find(&proxyHosts)
|
|
domainToName := make(map[string]string)
|
|
for _, ph := range proxyHosts {
|
|
if ph.CertificateID != nil {
|
|
certInUse[*ph.CertificateID] = true
|
|
}
|
|
if ph.Name == "" {
|
|
continue
|
|
}
|
|
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 := certStatus(c)
|
|
|
|
expires := time.Time{}
|
|
if c.ExpiresAt != nil {
|
|
expires = *c.ExpiresAt
|
|
}
|
|
|
|
notBefore := time.Time{}
|
|
if c.NotBefore != nil {
|
|
notBefore = *c.NotBefore
|
|
}
|
|
|
|
// Try to get name from proxy host, fall back to cert name or domain
|
|
name := c.Name
|
|
certDomains := strings.Split(c.Domains, ",")
|
|
for _, d := range certDomains {
|
|
d = strings.TrimSpace(strings.ToLower(d))
|
|
if phName, ok := domainToName[d]; ok {
|
|
name = phName
|
|
break
|
|
}
|
|
}
|
|
|
|
chainDepth := 0
|
|
if c.CertificateChain != "" {
|
|
rest := []byte(c.CertificateChain)
|
|
for {
|
|
var block *pem.Block
|
|
block, rest = pem.Decode(rest)
|
|
if block == nil {
|
|
break
|
|
}
|
|
chainDepth++
|
|
}
|
|
}
|
|
|
|
certs = append(certs, CertificateInfo{
|
|
UUID: c.UUID,
|
|
Name: name,
|
|
CommonName: c.CommonName,
|
|
Domains: c.Domains,
|
|
Issuer: c.Provider,
|
|
IssuerOrg: c.IssuerOrg,
|
|
Fingerprint: c.Fingerprint,
|
|
SerialNumber: c.SerialNumber,
|
|
KeyType: c.KeyType,
|
|
ExpiresAt: expires,
|
|
NotBefore: notBefore,
|
|
Status: status,
|
|
Provider: c.Provider,
|
|
ChainDepth: chainDepth,
|
|
HasKey: c.PrivateKeyEncrypted != "",
|
|
InUse: certInUse[c.ID],
|
|
})
|
|
}
|
|
|
|
s.cache = certs
|
|
return nil
|
|
}
|
|
|
|
func certStatus(c models.SSLCertificate) string {
|
|
if strings.Contains(c.Provider, "staging") {
|
|
return "untrusted"
|
|
}
|
|
if c.ExpiresAt != nil {
|
|
if time.Now().After(*c.ExpiresAt) {
|
|
return "expired"
|
|
}
|
|
if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) {
|
|
return "expiring"
|
|
}
|
|
}
|
|
return "valid"
|
|
}
|
|
|
|
// 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 with full validation and encryption.
|
|
func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM, chainPEM string) (*CertificateInfo, error) {
|
|
parsed, err := ParseCertificateInput([]byte(certPEM), []byte(keyPEM), []byte(chainPEM), "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse certificate input: %w", err)
|
|
}
|
|
|
|
// Validate key matches certificate if key is provided
|
|
if parsed.PrivateKey != nil {
|
|
if err := ValidateKeyMatch(parsed.Leaf, parsed.PrivateKey); err != nil {
|
|
return nil, fmt.Errorf("key validation failed: %w", err)
|
|
}
|
|
}
|
|
|
|
// Extract metadata
|
|
meta := ExtractCertificateMetadata(parsed.Leaf)
|
|
|
|
domains := meta.CommonName
|
|
if len(parsed.Leaf.DNSNames) > 0 {
|
|
domains = strings.Join(parsed.Leaf.DNSNames, ",")
|
|
}
|
|
|
|
notAfter := parsed.Leaf.NotAfter
|
|
notBefore := parsed.Leaf.NotBefore
|
|
|
|
sslCert := &models.SSLCertificate{
|
|
UUID: uuid.New().String(),
|
|
Name: name,
|
|
Provider: "custom",
|
|
Domains: domains,
|
|
CommonName: meta.CommonName,
|
|
Certificate: parsed.CertPEM,
|
|
CertificateChain: parsed.ChainPEM,
|
|
Fingerprint: meta.Fingerprint,
|
|
SerialNumber: meta.SerialNumber,
|
|
IssuerOrg: meta.IssuerOrg,
|
|
KeyType: meta.KeyType,
|
|
ExpiresAt: ¬After,
|
|
NotBefore: ¬Before,
|
|
KeyVersion: 1,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
// Encrypt private key at rest
|
|
if parsed.KeyPEM != "" && s.encSvc != nil {
|
|
encrypted, err := s.encSvc.Encrypt([]byte(parsed.KeyPEM))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
|
|
}
|
|
sslCert.PrivateKeyEncrypted = encrypted
|
|
}
|
|
|
|
if err := s.db.Create(sslCert).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to save certificate: %w", err)
|
|
}
|
|
|
|
s.InvalidateCache()
|
|
|
|
chainDepth := len(parsed.Intermediates)
|
|
|
|
info := &CertificateInfo{
|
|
UUID: sslCert.UUID,
|
|
Name: sslCert.Name,
|
|
CommonName: sslCert.CommonName,
|
|
Domains: sslCert.Domains,
|
|
Issuer: sslCert.Provider,
|
|
IssuerOrg: sslCert.IssuerOrg,
|
|
Fingerprint: sslCert.Fingerprint,
|
|
SerialNumber: sslCert.SerialNumber,
|
|
KeyType: sslCert.KeyType,
|
|
ExpiresAt: notAfter,
|
|
NotBefore: notBefore,
|
|
Status: certStatus(*sslCert),
|
|
Provider: sslCert.Provider,
|
|
ChainDepth: chainDepth,
|
|
HasKey: sslCert.PrivateKeyEncrypted != "",
|
|
InUse: false,
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// GetCertificate returns full certificate detail by UUID.
|
|
func (s *CertificateService) GetCertificate(certUUID string) (*CertificateDetail, error) {
|
|
var cert models.SSLCertificate
|
|
if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, ErrCertNotFound
|
|
}
|
|
return nil, fmt.Errorf("failed to fetch certificate: %w", err)
|
|
}
|
|
|
|
// Get assigned hosts
|
|
var hosts []models.ProxyHost
|
|
s.db.Where("certificate_id = ?", cert.ID).Find(&hosts)
|
|
assignedHosts := make([]AssignedHostInfo, 0, len(hosts))
|
|
for _, h := range hosts {
|
|
assignedHosts = append(assignedHosts, AssignedHostInfo{
|
|
UUID: h.UUID,
|
|
Name: h.Name,
|
|
DomainNames: h.DomainNames,
|
|
})
|
|
}
|
|
|
|
// Parse chain entries
|
|
chain := buildChainEntries(cert.Certificate, cert.CertificateChain)
|
|
|
|
expires := time.Time{}
|
|
if cert.ExpiresAt != nil {
|
|
expires = *cert.ExpiresAt
|
|
}
|
|
notBefore := time.Time{}
|
|
if cert.NotBefore != nil {
|
|
notBefore = *cert.NotBefore
|
|
}
|
|
|
|
detail := &CertificateDetail{
|
|
UUID: cert.UUID,
|
|
Name: cert.Name,
|
|
CommonName: cert.CommonName,
|
|
Domains: cert.Domains,
|
|
Issuer: cert.Provider,
|
|
IssuerOrg: cert.IssuerOrg,
|
|
Fingerprint: cert.Fingerprint,
|
|
SerialNumber: cert.SerialNumber,
|
|
KeyType: cert.KeyType,
|
|
ExpiresAt: expires,
|
|
NotBefore: notBefore,
|
|
Status: certStatus(cert),
|
|
Provider: cert.Provider,
|
|
ChainDepth: len(chain),
|
|
HasKey: cert.PrivateKeyEncrypted != "",
|
|
InUse: len(hosts) > 0,
|
|
AssignedHosts: assignedHosts,
|
|
Chain: chain,
|
|
AutoRenew: cert.AutoRenew,
|
|
CreatedAt: cert.CreatedAt,
|
|
UpdatedAt: cert.UpdatedAt,
|
|
}
|
|
|
|
return detail, nil
|
|
}
|
|
|
|
// ValidateCertificate validates certificate data without storing.
|
|
func (s *CertificateService) ValidateCertificate(certPEM, keyPEM, chainPEM string) (*ValidationResult, error) {
|
|
result := &ValidationResult{
|
|
Warnings: []string{},
|
|
Errors: []string{},
|
|
}
|
|
|
|
parsed, err := ParseCertificateInput([]byte(certPEM), []byte(keyPEM), []byte(chainPEM), "")
|
|
if err != nil {
|
|
result.Errors = append(result.Errors, err.Error())
|
|
return result, nil
|
|
}
|
|
|
|
meta := ExtractCertificateMetadata(parsed.Leaf)
|
|
result.CommonName = meta.CommonName
|
|
result.Domains = meta.Domains
|
|
result.IssuerOrg = meta.IssuerOrg
|
|
result.ExpiresAt = meta.NotAfter
|
|
result.ChainDepth = len(parsed.Intermediates)
|
|
|
|
// Key match check
|
|
if parsed.PrivateKey != nil {
|
|
if err := ValidateKeyMatch(parsed.Leaf, parsed.PrivateKey); err != nil {
|
|
result.Errors = append(result.Errors, fmt.Sprintf("key mismatch: %s", err.Error()))
|
|
} else {
|
|
result.KeyMatch = true
|
|
}
|
|
}
|
|
|
|
// Chain validation (best-effort, warn on failure)
|
|
if len(parsed.Intermediates) > 0 {
|
|
if err := ValidateChain(parsed.Leaf, parsed.Intermediates); err != nil {
|
|
result.Warnings = append(result.Warnings, fmt.Sprintf("chain validation: %s", err.Error()))
|
|
} else {
|
|
result.ChainValid = true
|
|
}
|
|
} else {
|
|
// Try verifying with system roots
|
|
if err := ValidateChain(parsed.Leaf, nil); err != nil {
|
|
result.Warnings = append(result.Warnings, "certificate could not be verified against system roots")
|
|
} else {
|
|
result.ChainValid = true
|
|
}
|
|
}
|
|
|
|
// Expiry warnings
|
|
daysUntilExpiry := time.Until(parsed.Leaf.NotAfter).Hours() / 24
|
|
if daysUntilExpiry < 0 {
|
|
result.Warnings = append(result.Warnings, "Certificate has expired")
|
|
} else if daysUntilExpiry < 30 {
|
|
result.Warnings = append(result.Warnings, fmt.Sprintf("Certificate expires in %.0f days", daysUntilExpiry))
|
|
}
|
|
|
|
result.Valid = len(result.Errors) == 0
|
|
return result, 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
|
|
}
|
|
|
|
// IsCertificateInUseByUUID checks if a certificate is referenced by any proxy host, looked up by UUID.
|
|
func (s *CertificateService) IsCertificateInUseByUUID(certUUID string) (bool, error) {
|
|
var cert models.SSLCertificate
|
|
if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return false, ErrCertNotFound
|
|
}
|
|
return false, fmt.Errorf("failed to look up certificate: %w", err)
|
|
}
|
|
return s.IsCertificateInUse(cert.ID)
|
|
}
|
|
|
|
// DeleteCertificate removes a certificate by UUID.
|
|
func (s *CertificateService) DeleteCertificate(certUUID string) error {
|
|
var cert models.SSLCertificate
|
|
if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return ErrCertNotFound
|
|
}
|
|
return fmt.Errorf("failed to look up certificate: %w", err)
|
|
}
|
|
|
|
// Prevent deletion if the certificate is referenced by any proxy host
|
|
inUse, err := s.IsCertificateInUse(cert.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if inUse {
|
|
return ErrCertInUse
|
|
}
|
|
|
|
if cert.Provider == "letsencrypt" || cert.Provider == "letsencrypt-staging" {
|
|
// 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" {
|
|
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")
|
|
}
|
|
keyPath := strings.TrimSuffix(path, ".crt") + ".key"
|
|
if _, err := os.Stat(keyPath); err == nil {
|
|
if err := os.Remove(keyPath); err != nil {
|
|
logger.Log().WithError(err).Warn("Failed to remove key file")
|
|
}
|
|
}
|
|
jsonPath := strings.TrimSuffix(path, ".crt") + ".json"
|
|
if _, err := os.Stat(jsonPath); err == nil {
|
|
if err := os.Remove(jsonPath); err != nil {
|
|
logger.Log().WithError(err).Warn("Failed to remove JSON file")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", cert.ID).Error; err != nil {
|
|
return fmt.Errorf("failed to delete certificate: %w", err)
|
|
}
|
|
s.InvalidateCache()
|
|
return nil
|
|
}
|
|
|
|
// ExportCertificate exports a certificate in the requested format.
|
|
// Returns the file data, suggested filename, and any error.
|
|
func (s *CertificateService) ExportCertificate(certUUID string, format string, includeKey bool, pfxPassword string) ([]byte, string, error) {
|
|
var cert models.SSLCertificate
|
|
if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, "", ErrCertNotFound
|
|
}
|
|
return nil, "", fmt.Errorf("failed to fetch certificate: %w", err)
|
|
}
|
|
|
|
baseName := cert.Name
|
|
if baseName == "" {
|
|
baseName = "certificate"
|
|
}
|
|
|
|
switch strings.ToLower(format) {
|
|
case "pem":
|
|
var buf strings.Builder
|
|
buf.WriteString(cert.Certificate)
|
|
if cert.CertificateChain != "" {
|
|
buf.WriteString("\n")
|
|
buf.WriteString(cert.CertificateChain)
|
|
}
|
|
if includeKey {
|
|
keyPEM, err := s.GetDecryptedPrivateKey(&cert)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to decrypt private key: %w", err)
|
|
}
|
|
buf.WriteString("\n")
|
|
buf.WriteString(keyPEM)
|
|
}
|
|
return []byte(buf.String()), baseName + ".pem", nil
|
|
|
|
case "der":
|
|
derData, err := ConvertPEMToDER(cert.Certificate)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to convert to DER: %w", err)
|
|
}
|
|
return derData, baseName + ".der", nil
|
|
|
|
case "pfx", "p12":
|
|
keyPEM, err := s.GetDecryptedPrivateKey(&cert)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to decrypt private key for PFX: %w", err)
|
|
}
|
|
pfxData, err := ConvertPEMToPFX(cert.Certificate, keyPEM, cert.CertificateChain, pfxPassword)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to create PFX: %w", err)
|
|
}
|
|
return pfxData, baseName + ".pfx", nil
|
|
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported export format: %s", format)
|
|
}
|
|
}
|
|
|
|
// GetDecryptedPrivateKey decrypts and returns the private key PEM for internal use.
|
|
func (s *CertificateService) GetDecryptedPrivateKey(cert *models.SSLCertificate) (string, error) {
|
|
if cert.PrivateKeyEncrypted == "" {
|
|
return "", fmt.Errorf("no encrypted private key stored")
|
|
}
|
|
if s.encSvc == nil {
|
|
return "", fmt.Errorf("encryption service not configured")
|
|
}
|
|
|
|
decrypted, err := s.encSvc.Decrypt(cert.PrivateKeyEncrypted)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to decrypt private key: %w", err)
|
|
}
|
|
|
|
return string(decrypted), nil
|
|
}
|
|
|
|
// MigratePrivateKeys encrypts existing plaintext private keys.
|
|
// Idempotent — skips already-migrated rows.
|
|
func (s *CertificateService) MigratePrivateKeys() error {
|
|
if s.encSvc == nil {
|
|
logger.Log().Warn("CertificateService: encryption service not configured, skipping key migration")
|
|
return nil
|
|
}
|
|
|
|
// Use raw SQL because PrivateKey has gorm:"-" tag
|
|
type rawCert struct {
|
|
ID uint
|
|
PrivateKey string
|
|
PrivateKeyEnc string `gorm:"column:private_key_enc"`
|
|
}
|
|
|
|
var certs []rawCert
|
|
if err := s.db.Raw("SELECT id, private_key, private_key_enc FROM ssl_certificates WHERE private_key != '' AND (private_key_enc = '' OR private_key_enc IS NULL)").Scan(&certs).Error; err != nil {
|
|
return fmt.Errorf("failed to query certificates for migration: %w", err)
|
|
}
|
|
|
|
if len(certs) == 0 {
|
|
logger.Log().Info("CertificateService: no private keys to migrate")
|
|
return nil
|
|
}
|
|
|
|
logger.Log().WithField("count", len(certs)).Info("CertificateService: migrating plaintext private keys")
|
|
|
|
for _, c := range certs {
|
|
encrypted, err := s.encSvc.Encrypt([]byte(c.PrivateKey))
|
|
if err != nil {
|
|
logger.Log().WithField("cert_id", c.ID).WithError(err).Error("CertificateService: failed to encrypt key during migration")
|
|
continue
|
|
}
|
|
|
|
if err := s.db.Exec("UPDATE ssl_certificates SET private_key_enc = ?, key_version = 1, private_key = '' WHERE id = ?", encrypted, c.ID).Error; err != nil {
|
|
logger.Log().WithField("cert_id", c.ID).WithError(err).Error("CertificateService: failed to update migrated key")
|
|
continue
|
|
}
|
|
|
|
logger.Log().WithField("cert_id", c.ID).Info("CertificateService: migrated private key")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteCertificateByID removes a certificate by numeric ID (legacy compatibility).
|
|
func (s *CertificateService) DeleteCertificateByID(id uint) error {
|
|
var cert models.SSLCertificate
|
|
if err := s.db.Where("id = ?", id).First(&cert).Error; err != nil {
|
|
return fmt.Errorf("failed to look up certificate: %w", err)
|
|
}
|
|
return s.DeleteCertificate(cert.UUID)
|
|
}
|
|
|
|
// UpdateCertificate updates certificate metadata (name only) by UUID.
|
|
func (s *CertificateService) UpdateCertificate(certUUID string, name string) (*CertificateInfo, error) {
|
|
var cert models.SSLCertificate
|
|
if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, ErrCertNotFound
|
|
}
|
|
return nil, fmt.Errorf("failed to fetch certificate: %w", err)
|
|
}
|
|
|
|
cert.Name = name
|
|
if err := s.db.Save(&cert).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to update certificate: %w", err)
|
|
}
|
|
|
|
s.InvalidateCache()
|
|
|
|
expires := time.Time{}
|
|
if cert.ExpiresAt != nil {
|
|
expires = *cert.ExpiresAt
|
|
}
|
|
notBefore := time.Time{}
|
|
if cert.NotBefore != nil {
|
|
notBefore = *cert.NotBefore
|
|
}
|
|
|
|
var chainDepth int
|
|
if cert.CertificateChain != "" {
|
|
certs, _ := parsePEMCertificates([]byte(cert.CertificateChain))
|
|
chainDepth = len(certs)
|
|
}
|
|
|
|
inUse, _ := s.IsCertificateInUse(cert.ID)
|
|
|
|
return &CertificateInfo{
|
|
UUID: cert.UUID,
|
|
Name: cert.Name,
|
|
CommonName: cert.CommonName,
|
|
Domains: cert.Domains,
|
|
Issuer: cert.Provider,
|
|
IssuerOrg: cert.IssuerOrg,
|
|
Fingerprint: cert.Fingerprint,
|
|
SerialNumber: cert.SerialNumber,
|
|
KeyType: cert.KeyType,
|
|
ExpiresAt: expires,
|
|
NotBefore: notBefore,
|
|
Status: certStatus(cert),
|
|
Provider: cert.Provider,
|
|
ChainDepth: chainDepth,
|
|
HasKey: cert.PrivateKeyEncrypted != "",
|
|
InUse: inUse,
|
|
}, nil
|
|
}
|
|
|
|
// CheckExpiringCertificates returns certificates that are expiring within the given number of days.
|
|
func (s *CertificateService) CheckExpiringCertificates(warningDays int) ([]CertificateInfo, error) {
|
|
var certs []models.SSLCertificate
|
|
threshold := time.Now().Add(time.Duration(warningDays) * 24 * time.Hour)
|
|
|
|
if err := s.db.Where("provider = ? AND expires_at IS NOT NULL AND expires_at <= ?", "custom", threshold).Find(&certs).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to query expiring certificates: %w", err)
|
|
}
|
|
|
|
result := make([]CertificateInfo, 0, len(certs))
|
|
for _, cert := range certs {
|
|
expires := time.Time{}
|
|
if cert.ExpiresAt != nil {
|
|
expires = *cert.ExpiresAt
|
|
}
|
|
notBefore := time.Time{}
|
|
if cert.NotBefore != nil {
|
|
notBefore = *cert.NotBefore
|
|
}
|
|
|
|
result = append(result, CertificateInfo{
|
|
UUID: cert.UUID,
|
|
Name: cert.Name,
|
|
CommonName: cert.CommonName,
|
|
Domains: cert.Domains,
|
|
Issuer: cert.Provider,
|
|
IssuerOrg: cert.IssuerOrg,
|
|
Fingerprint: cert.Fingerprint,
|
|
SerialNumber: cert.SerialNumber,
|
|
KeyType: cert.KeyType,
|
|
ExpiresAt: expires,
|
|
NotBefore: notBefore,
|
|
Status: certStatus(cert),
|
|
Provider: cert.Provider,
|
|
HasKey: cert.PrivateKeyEncrypted != "",
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// StartExpiryChecker runs a background goroutine that periodically checks for expiring certificates.
|
|
func (s *CertificateService) StartExpiryChecker(ctx context.Context, notificationSvc *NotificationService, warningDays int) {
|
|
// Startup delay: avoid notification bursts during frequent restarts
|
|
startupDelay := 5 * time.Minute
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(startupDelay):
|
|
}
|
|
|
|
// Add random jitter (0-60 minutes) using crypto/rand
|
|
maxJitter := int64(60 * time.Minute)
|
|
n, errRand := crand.Int(crand.Reader, big.NewInt(maxJitter))
|
|
if errRand != nil {
|
|
n = big.NewInt(maxJitter / 2)
|
|
}
|
|
jitter := time.Duration(n.Int64())
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(jitter):
|
|
}
|
|
|
|
s.checkExpiry(ctx, notificationSvc, warningDays)
|
|
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.checkExpiry(ctx, notificationSvc, warningDays)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *CertificateService) checkExpiry(ctx context.Context, notificationSvc *NotificationService, warningDays int) {
|
|
if notificationSvc == nil {
|
|
return
|
|
}
|
|
|
|
certs, err := s.CheckExpiringCertificates(warningDays)
|
|
if err != nil {
|
|
logger.Log().WithError(err).Error("CertificateService: failed to check expiring certificates")
|
|
return
|
|
}
|
|
|
|
for _, cert := range certs {
|
|
daysLeft := time.Until(cert.ExpiresAt).Hours() / 24
|
|
|
|
if daysLeft < 0 {
|
|
// Expired
|
|
if _, err := notificationSvc.Create(
|
|
models.NotificationTypeError,
|
|
"Certificate Expired",
|
|
fmt.Sprintf("Certificate %q (%s) has expired.", cert.Name, cert.Domains),
|
|
); err != nil {
|
|
logger.Log().WithError(err).Error("CertificateService: failed to create expiry notification")
|
|
}
|
|
notificationSvc.SendExternal(ctx,
|
|
"cert_expiry",
|
|
"Certificate Expired",
|
|
fmt.Sprintf("Certificate %q (%s) has expired.", cert.Name, cert.Domains),
|
|
map[string]any{"uuid": cert.UUID, "domains": cert.Domains, "status": "expired"},
|
|
)
|
|
} else {
|
|
// Expiring soon
|
|
if _, err := notificationSvc.Create(
|
|
models.NotificationTypeWarning,
|
|
"Certificate Expiring Soon",
|
|
fmt.Sprintf("Certificate %q (%s) expires in %.0f days.", cert.Name, cert.Domains, daysLeft),
|
|
); err != nil {
|
|
logger.Log().WithError(err).Error("CertificateService: failed to create expiry warning notification")
|
|
}
|
|
notificationSvc.SendExternal(ctx,
|
|
"cert_expiry",
|
|
"Certificate Expiring Soon",
|
|
fmt.Sprintf("Certificate %q (%s) expires in %.0f days.", cert.Name, cert.Domains, daysLeft),
|
|
map[string]any{"uuid": cert.UUID, "domains": cert.Domains, "days_left": int(daysLeft)},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildChainEntries(certPEM, chainPEM string) []ChainEntry {
|
|
var entries []ChainEntry
|
|
|
|
// Parse leaf
|
|
if certPEM != "" {
|
|
certs, _ := parsePEMCertificates([]byte(certPEM))
|
|
for _, c := range certs {
|
|
entries = append(entries, ChainEntry{
|
|
Subject: c.Subject.CommonName,
|
|
Issuer: c.Issuer.CommonName,
|
|
ExpiresAt: c.NotAfter,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Parse chain
|
|
if chainPEM != "" {
|
|
certs, _ := parsePEMCertificates([]byte(chainPEM))
|
|
for _, c := range certs {
|
|
entries = append(entries, ChainEntry{
|
|
Subject: c.Subject.CommonName,
|
|
Issuer: c.Issuer.CommonName,
|
|
ExpiresAt: c.NotAfter,
|
|
})
|
|
}
|
|
}
|
|
|
|
return entries
|
|
}
|