Przeglądaj źródła

refactor: replace SQLite/GORM with JSON file storage

- Remove GORM and SQLite, use simple JSON file (data/certs.json) for persistence
- CertStore with sync.RWMutex for thread-safe in-memory cache
- All handlers updated to use config.Store instead of config.DB
- Cron job and stats updated accordingly
- go mod tidy removes unused gorm/sqlite dependencies
cnbugs 1 tydzień temu
rodzic
commit
3fa77d9bc0
6 zmienionych plików z 292 dodań i 164 usunięć
  1. 15 23
      backend/config/certificate.go
  2. 155 22
      backend/config/config.go
  3. 0 5
      backend/go.mod
  4. 0 10
      backend/go.sum
  5. 117 98
      backend/handlers/cert.go
  6. 5 6
      backend/main.go

+ 15 - 23
backend/config/certificate.go

@@ -1,33 +1,25 @@
 package config
 
-import (
-	"time"
-	"gorm.io/gorm"
-)
+import "time"
 
 type Certificate struct {
-	ID            uint           `gorm:"primarykey" json:"id"`
-	CreatedAt     time.Time      `json:"created_at"`
-	UpdatedAt     time.Time      `json:"updated_at"`
-	DeletedAt     gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
+	ID            uint       `json:"id"`
+	CreatedAt     time.Time  `json:"created_at"`
+	UpdatedAt     time.Time  `json:"updated_at"`
 
-	Domain        string `json:"domain" gorm:"uniqueIndex;size:255"`
-	Email         string `json:"email" gorm:"size:255"`
-	Provider      string `json:"provider" gorm:"size:50;default:letsencrypt"` // letsencrypt, zerossl
-	ChallengeType string `json:"challenge_type" gorm:"size:20;default:http"`  // http, dns
-	DNSProvider   string `json:"dns_provider,omitempty" gorm:"size:50"`       // alidns, cloudflare, etc.
-	DNSConfig     string `json:"dns_config,omitempty" gorm:"type:text"`       // JSON config for DNS provider
+	Domain        string `json:"domain"`
+	Email         string `json:"email"`
+	Provider      string `json:"provider"`       // letsencrypt, zerossl
+	ChallengeType string `json:"challenge_type"` // http, dns
+	DNSProvider   string `json:"dns_provider,omitempty"` // alidns, cloudflare, etc.
+	DNSConfig     string `json:"dns_config,omitempty"`  // JSON config for DNS provider
 
-	Status        string `json:"status" gorm:"size:20;default:pending"` // pending, active, expired, error
-	CertURL       string `json:"cert_url,omitempty" gorm:"size:512"`
+	Status        string    `json:"status"` // pending, active, expired, error
+	CertURL       string    `json:"cert_url,omitempty"`
 	ExpiresAt     *time.Time `json:"expires_at,omitempty"`
 	LastRenewedAt *time.Time `json:"last_renewed_at,omitempty"`
-	ErrorMessage  string `json:"error_message,omitempty" gorm:"type:text"`
+	ErrorMessage  string    `json:"error_message,omitempty"`
 
-	// Auto renew settings
-	AutoRenew     bool   `json:"auto_renew" gorm:"default:true"`
-	RenewDays     int    `json:"renew_days" gorm:"default:30"` // Renew when expires within this many days
-
-	// ACME account key
-	AccountKeyID  uint   `json:"account_key_id,omitempty"`
+	AutoRenew     bool `json:"auto_renew"`
+	RenewDays     int  `json:"renew_days"` // Renew when expires within this many days
 }

+ 155 - 22
backend/config/config.go

@@ -1,20 +1,18 @@
 package config
 
 import (
-	"gorm.io/driver/sqlite"
-	"gorm.io/gorm"
-	"gorm.io/gorm/logger"
+	"encoding/json"
 	"log"
 	"os"
+	"sync"
+	"time"
 )
 
-var DB *gorm.DB
-
 type Config struct {
 	Port        string
-	DBPath      string
 	CertDir     string
 	AccountsDir string
+	DataDir     string
 }
 
 func Load() *Config {
@@ -22,10 +20,6 @@ func Load() *Config {
 	if port == "" {
 		port = "8080"
 	}
-	dbPath := os.Getenv("DB_PATH")
-	if dbPath == "" {
-		dbPath = "./data/autossl.db"
-	}
 	certDir := os.Getenv("CERT_DIR")
 	if certDir == "" {
 		certDir = "./data/certs"
@@ -34,36 +28,175 @@ func Load() *Config {
 	if accountsDir == "" {
 		accountsDir = "./data/accounts"
 	}
+	dataDir := os.Getenv("DATA_DIR")
+	if dataDir == "" {
+		dataDir = "./data"
+	}
 
 	return &Config{
 		Port:        port,
-		DBPath:      dbPath,
 		CertDir:     certDir,
 		AccountsDir: accountsDir,
+		DataDir:     dataDir,
 	}
 }
 
-func InitDB(cfg *Config) {
+// CertStore is the in-memory store for certificates with file persistence
+type CertStore struct {
+	mu    sync.RWMutex
+	data  map[string]*Certificate // key: domain
+	path  string
+	nextID uint
+}
+
+var Store *CertStore
+
+func InitStore(cfg *Config) {
 	// Ensure data directories exist
-	dirs := []string{"./data", cfg.CertDir, cfg.AccountsDir}
+	dirs := []string{cfg.DataDir, cfg.CertDir, cfg.AccountsDir}
 	for _, d := range dirs {
 		if err := os.MkdirAll(d, 0700); err != nil {
 			log.Fatalf("Failed to create directory %s: %v", d, err)
 		}
 	}
 
-	var err error
-	DB, err = gorm.Open(sqlite.Open(cfg.DBPath), &gorm.Config{
-		Logger: logger.Default.LogMode(logger.Warn),
-	})
+	Store = &CertStore{
+		data: make(map[string]*Certificate),
+		path: cfg.DataDir + "/certs.json",
+	}
+
+	if err := Store.Load(); err != nil {
+		log.Printf("No existing cert store or failed to load: %v, starting fresh", err)
+	}
+
+	log.Println("Cert store initialized successfully")
+}
+
+// Load reads certificates from JSON file into memory
+func (s *CertStore) Load() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	data, err := os.ReadFile(s.path)
+	if err != nil {
+		return err
+	}
+
+	var certs []*Certificate
+	if err := json.Unmarshal(data, &certs); err != nil {
+		return err
+	}
+
+	s.data = make(map[string]*Certificate)
+	s.nextID = 0
+	for _, c := range certs {
+		s.data[c.Domain] = c
+		if c.ID >= s.nextID {
+			s.nextID = c.ID + 1
+		}
+	}
+
+	return nil
+}
+
+// Save writes the current in-memory certificates to JSON file
+func (s *CertStore) Save() error {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	certs := make([]*Certificate, 0, len(s.data))
+	for _, c := range s.data {
+		certs = append(certs, c)
+	}
+
+	data, err := json.MarshalIndent(certs, "", "  ")
 	if err != nil {
-		log.Fatalf("Failed to connect database: %v", err)
+		return err
 	}
 
-	// Auto migrate
-	if err := DB.AutoMigrate(&Certificate{}); err != nil {
-		log.Fatalf("Failed to migrate database: %v", err)
+	return os.WriteFile(s.path, data, 0600)
+}
+
+// GetAll returns all certificates sorted by ID descending
+func (s *CertStore) GetAll() []*Certificate {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	certs := make([]*Certificate, 0, len(s.data))
+	for _, c := range s.data {
+		certs = append(certs, c)
+	}
+
+	// Sort by ID descending (newest first)
+	for i := 0; i < len(certs)-1; i++ {
+		for j := i + 1; j < len(certs); j++ {
+			if certs[i].ID < certs[j].ID {
+				certs[i], certs[j] = certs[j], certs[i]
+			}
+		}
+	}
+	return certs
+}
+
+// GetByDomain returns a certificate by domain
+func (s *CertStore) GetByDomain(domain string) *Certificate {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	return s.data[domain]
+}
+
+// GetByID returns a certificate by ID
+func (s *CertStore) GetByID(id uint) *Certificate {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	for _, c := range s.data {
+		if c.ID == id {
+			return c
+		}
+	}
+	return nil
+}
+
+// Upsert inserts or updates a certificate
+func (s *CertStore) Upsert(cert *Certificate) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if cert.ID == 0 {
+		cert.ID = s.nextID
+		s.nextID++
+		cert.CreatedAt = time.Now()
 	}
+	cert.UpdatedAt = time.Now()
 
-	log.Println("Database initialized successfully")
+	s.data[cert.Domain] = cert
+	return s.Save()
+}
+
+// Delete removes a certificate by ID
+func (s *CertStore) Delete(id uint) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	for domain, c := range s.data {
+		if c.ID == id {
+			delete(s.data, domain)
+			return s.Save()
+		}
+	}
+	return nil
+}
+
+// GetActiveWithAutoRenew returns all active certs with auto-renewal enabled
+func (s *CertStore) GetActiveWithAutoRenew() []*Certificate {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	var result []*Certificate
+	for _, c := range s.data {
+		if c.AutoRenew && c.Status == "active" {
+			result = append(result, c)
+		}
+	}
+	return result
 }

+ 0 - 5
backend/go.mod

@@ -7,8 +7,6 @@ require (
 	github.com/gin-gonic/gin v1.9.1
 	github.com/go-acme/lego/v4 v4.14.2
 	github.com/robfig/cron/v3 v3.0.1
-	gorm.io/driver/sqlite v1.5.4
-	gorm.io/gorm v1.25.5
 )
 
 require (
@@ -28,14 +26,11 @@ require (
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-retryablehttp v0.7.4 // indirect
-	github.com/jinzhu/inflection v1.0.0 // indirect
-	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/klauspost/cpuid/v2 v2.2.5 // indirect
 	github.com/leodido/go-urn v1.2.4 // indirect
 	github.com/mattn/go-isatty v0.0.19 // indirect
-	github.com/mattn/go-sqlite3 v1.14.17 // indirect
 	github.com/miekg/dns v1.1.55 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect

+ 0 - 10
backend/go.sum

@@ -58,10 +58,6 @@ github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXc
 github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
 github.com/hashicorp/go-retryablehttp v0.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA=
 github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
-github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
-github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
-github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
-github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
@@ -86,8 +82,6 @@ github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZb
 github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
 github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
 github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
-github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
 github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo=
 github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -168,9 +162,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
-gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
-gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
-gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
 nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
 rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

+ 117 - 98
backend/handlers/cert.go

@@ -7,10 +7,10 @@ import (
 	"net/http"
 	"path/filepath"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/gin-gonic/gin"
-	"strings"
 )
 
 type CertHandler struct {
@@ -21,13 +21,20 @@ func NewCertHandler(cfg *config.Config) *CertHandler {
 	return &CertHandler{Cfg: cfg}
 }
 
+type CreateCertRequest struct {
+	Domain        string `json:"domain" binding:"required"`
+	Email         string `json:"email" binding:"required"`
+	Provider      string `json:"provider"`
+	ChallengeType string `json:"challenge_type"`
+	DNSProvider   string `json:"dns_provider"`
+	DNSConfig     string `json:"dns_config"`
+	AutoRenew     *bool  `json:"auto_renew"`
+	RenewDays     *int   `json:"renew_days"`
+}
+
 // ListCertificates returns all certificates
 func (h *CertHandler) ListCertificates(c *gin.Context) {
-	var certs []config.Certificate
-	if err := config.DB.Order("created_at desc").Find(&certs).Error; err != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
-	}
+	certs := config.Store.GetAll()
 	c.JSON(http.StatusOK, certs)
 }
 
@@ -38,25 +45,14 @@ func (h *CertHandler) GetCertificate(c *gin.Context) {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
 		return
 	}
-	var cert config.Certificate
-	if err := config.DB.First(&cert, id).Error; err != nil {
+	cert := config.Store.GetByID(uint(id))
+	if cert == nil {
 		c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
 		return
 	}
 	c.JSON(http.StatusOK, cert)
 }
 
-type CreateCertRequest struct {
-	Domain        string `json:"domain" binding:"required"`
-	Email         string `json:"email" binding:"required"`
-	Provider      string `json:"provider"`       // letsencrypt, zerossl
-	ChallengeType string `json:"challenge_type"` // http, dns
-	DNSProvider   string `json:"dns_provider"`
-	DNSConfig     string `json:"dns_config"` // JSON
-	AutoRenew     *bool  `json:"auto_renew"`
-	RenewDays     *int   `json:"renew_days"`
-}
-
 // CreateCertificate creates a new certificate entry and starts issuance
 func (h *CertHandler) CreateCertificate(c *gin.Context) {
 	var req CreateCertRequest
@@ -72,56 +68,81 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
 		req.ChallengeType = "http"
 	}
 
-	// Trim spaces from domain
 	req.Domain = strings.TrimSpace(req.Domain)
 
-	// Use SQLite upsert (INSERT ... ON CONFLICT) to atomically handle domain uniqueness
-	// This avoids the race condition between checking existence and inserting
-	result := config.DB.Exec(`
-		INSERT INTO certificates (domain, email, provider, challenge_type, dns_provider, dns_config, status, auto_renew, renew_days, created_at, updated_at)
-		VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, datetime('now'), datetime('now'))
-		ON CONFLICT(domain) DO UPDATE SET
-			email = excluded.email,
-			provider = excluded.provider,
-			challenge_type = excluded.challenge_type,
-			dns_provider = excluded.dns_provider,
-			dns_config = excluded.dns_config,
-			status = CASE 
-				WHEN status IN ('error', 'expired', 'pending') THEN 'pending'
-				ELSE status
-			END,
-			error_message = CASE 
-				WHEN status IN ('error', 'expired', 'pending') THEN ''
-				ELSE error_message
-			END,
-			updated_at = datetime('now')
-		WHERE excluded.domain = certificates.domain
-	`, req.Domain, req.Email, req.Provider, req.ChallengeType, req.DNSProvider, req.DNSConfig, req.AutoRenew != nil && *req.AutoRenew, req.RenewDays)
-
-	if result.Error != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
+	autoRenew := true
+	renewDays := 30
+	if req.AutoRenew != nil {
+		autoRenew = *req.AutoRenew
+	}
+	if req.RenewDays != nil {
+		renewDays = *req.RenewDays
+	}
+
+	existing := config.Store.GetByDomain(req.Domain)
+	if existing != nil {
+		// Domain exists: only retry if it's in a retryable state
+		if existing.Status == "error" || existing.Status == "expired" || existing.Status == "pending" {
+			existing.Email = req.Email
+			existing.Provider = req.Provider
+			existing.ChallengeType = req.ChallengeType
+			existing.DNSProvider = req.DNSProvider
+			existing.DNSConfig = req.DNSConfig
+			existing.Status = "pending"
+			existing.ErrorMessage = ""
+			existing.AutoRenew = autoRenew
+			existing.RenewDays = renewDays
+			config.Store.Upsert(existing)
+
+			// Start issuance in background
+			go func() {
+				domain := existing.Domain
+				saved := config.Store.GetByDomain(domain)
+				if err := services.GetACMECertificate(saved, h.Cfg); err != nil {
+					saved.Status = "error"
+					saved.ErrorMessage = err.Error()
+				} else {
+					saved.Status = "active"
+				}
+				config.Store.Upsert(saved)
+			}()
+
+			c.JSON(http.StatusAccepted, gin.H{"message": "certificate re-issuance started", "certificate": existing})
+			return
+		}
+		c.JSON(http.StatusConflict, gin.H{"error": "domain already exists with status: " + existing.Status})
 		return
 	}
 
-	// Reload to get the current state
-	var cert config.Certificate
-	config.DB.Where("domain = ?", req.Domain).First(&cert)
+	// Create new certificate
+	cert := &config.Certificate{
+		Domain:        req.Domain,
+		Email:         req.Email,
+		Provider:      req.Provider,
+		ChallengeType: req.ChallengeType,
+		DNSProvider:   req.DNSProvider,
+		DNSConfig:     req.DNSConfig,
+		Status:        "pending",
+		AutoRenew:     autoRenew,
+		RenewDays:     renewDays,
+	}
 
-	// If domain already existed and was active, return conflict
-	if cert.Status != "pending" {
-		c.JSON(http.StatusConflict, gin.H{"error": "domain already exists with status: " + cert.Status})
+	if err := config.Store.Upsert(cert); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
 		return
 	}
 
 	// Start issuance in background
 	go func() {
-		if err := services.GetACMECertificate(&cert, h.Cfg); err != nil {
-			cert.Status = "error"
-			cert.ErrorMessage = err.Error()
+		domain := cert.Domain
+		saved := config.Store.GetByDomain(domain)
+		if err := services.GetACMECertificate(saved, h.Cfg); err != nil {
+			saved.Status = "error"
+			saved.ErrorMessage = err.Error()
 		} else {
-			cert.Status = "active"
+			saved.Status = "active"
 		}
-		config.DB.Save(&cert)
+		config.Store.Upsert(saved)
 	}()
 
 	c.JSON(http.StatusAccepted, gin.H{"message": "certificate issuance started", "certificate": cert})
@@ -135,23 +156,25 @@ func (h *CertHandler) RenewCertificate(c *gin.Context) {
 		return
 	}
 
-	var cert config.Certificate
-	if err := config.DB.First(&cert, id).Error; err != nil {
+	cert := config.Store.GetByID(uint(id))
+	if cert == nil {
 		c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
 		return
 	}
 
 	cert.Status = "renewing"
-	config.DB.Save(&cert)
+	config.Store.Upsert(cert)
 
 	go func() {
-		if err := services.RenewCertificate(&cert, h.Cfg); err != nil {
-			cert.Status = "error"
-			cert.ErrorMessage = err.Error()
+		domain := cert.Domain
+		saved := config.Store.GetByDomain(domain)
+		if err := services.RenewCertificate(saved, h.Cfg); err != nil {
+			saved.Status = "error"
+			saved.ErrorMessage = err.Error()
 		} else {
-			cert.Status = "active"
+			saved.Status = "active"
 		}
-		config.DB.Save(&cert)
+		config.Store.Upsert(saved)
 	}()
 
 	c.JSON(http.StatusAccepted, gin.H{"message": "renewal started", "certificate": cert})
@@ -165,13 +188,7 @@ func (h *CertHandler) DeleteCertificate(c *gin.Context) {
 		return
 	}
 
-	var cert config.Certificate
-	if err := config.DB.First(&cert, id).Error; err != nil {
-		c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
-		return
-	}
-
-	if err := config.DB.Delete(&cert).Error; err != nil {
+	if err := config.Store.Delete(uint(id)); err != nil {
 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
 		return
 	}
@@ -187,8 +204,8 @@ func (h *CertHandler) UpdateCertificate(c *gin.Context) {
 		return
 	}
 
-	var cert config.Certificate
-	if err := config.DB.First(&cert, id).Error; err != nil {
+	cert := config.Store.GetByID(uint(id))
+	if cert == nil {
 		c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
 		return
 	}
@@ -199,25 +216,19 @@ func (h *CertHandler) UpdateCertificate(c *gin.Context) {
 		return
 	}
 
-	// Only allow updating certain fields
-	allowedFields := map[string]bool{
-		"auto_renew": true,
-		"renew_days": true,
-		"dns_config": true,
+	if v, ok := updates["auto_renew"]; ok {
+		cert.AutoRenew, _ = v.(bool)
 	}
-
-	filtered := make(map[string]interface{})
-	for k, v := range updates {
-		if allowedFields[k] {
-			filtered[k] = v
+	if v, ok := updates["renew_days"]; ok {
+		if f, ok := v.(float64); ok {
+			cert.RenewDays = int(f)
 		}
 	}
-
-	if err := config.DB.Model(&cert).Updates(filtered).Error; err != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
-		return
+	if v, ok := updates["dns_config"]; ok {
+		cert.DNSConfig, _ = v.(string)
 	}
 
+	config.Store.Upsert(cert)
 	c.JSON(http.StatusOK, cert)
 }
 
@@ -229,8 +240,8 @@ func (h *CertHandler) GetCertFiles(c *gin.Context) {
 		return
 	}
 
-	var cert config.Certificate
-	if err := config.DB.First(&cert, id).Error; err != nil {
+	cert := config.Store.GetByID(uint(id))
+	if cert == nil {
 		c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
 		return
 	}
@@ -248,15 +259,14 @@ func (h *CertHandler) GetCertFiles(c *gin.Context) {
 
 // CheckRenewals checks all certificates and renews those about to expire
 func (h *CertHandler) CheckRenewals(c *gin.Context) {
-	var certs []config.Certificate
-	config.DB.Where("auto_renew = ? AND status = ?", true, "active").Find(&certs)
+	certs := config.Store.GetActiveWithAutoRenew()
 
 	renewed := []string{}
 	failed := []string{}
 
 	for _, cert := range certs {
 		if cert.ExpiresAt != nil && time.Until(*cert.ExpiresAt).Hours() < float64(cert.RenewDays*24) {
-			if err := services.RenewCertificate(&cert, h.Cfg); err != nil {
+			if err := services.RenewCertificate(cert, h.Cfg); err != nil {
 				cert.Status = "error"
 				cert.ErrorMessage = fmt.Sprintf("auto renew failed: %v", err)
 				failed = append(failed, cert.Domain)
@@ -264,7 +274,7 @@ func (h *CertHandler) CheckRenewals(c *gin.Context) {
 				cert.Status = "active"
 				renewed = append(renewed, cert.Domain)
 			}
-			config.DB.Save(&cert)
+			config.Store.Upsert(cert)
 		}
 	}
 
@@ -277,11 +287,20 @@ func (h *CertHandler) CheckRenewals(c *gin.Context) {
 
 // Stats returns dashboard statistics
 func (h *CertHandler) Stats(c *gin.Context) {
-	var total, active, expired, errors int64
-	config.DB.Model(&config.Certificate{}).Count(&total)
-	config.DB.Model(&config.Certificate{}).Where("status = ?", "active").Count(&active)
-	config.DB.Model(&config.Certificate{}).Where("status = ?", "expired").Count(&expired)
-	config.DB.Model(&config.Certificate{}).Where("status = ?", "error").Count(&errors)
+	certs := config.Store.GetAll()
+
+	var total, active, expired, errors int
+	for _, cert := range certs {
+		total++
+		switch cert.Status {
+		case "active":
+			active++
+		case "expired":
+			expired++
+		case "error":
+			errors++
+		}
+	}
 
 	c.JSON(http.StatusOK, gin.H{
 		"total":   total,

+ 5 - 6
backend/main.go

@@ -16,8 +16,8 @@ import (
 func main() {
 	cfg := config.Load()
 
-	// Initialize database
-	config.InitDB(cfg)
+	// Initialize certificate store (JSON file persistence)
+	config.InitStore(cfg)
 
 	// Setup Gin
 	gin.SetMode(gin.ReleaseMode)
@@ -62,20 +62,19 @@ func main() {
 	c := cron.New()
 	c.AddFunc("0 3 * * *", func() {
 		log.Println("Running scheduled certificate renewal check...")
-		var certs []config.Certificate
-		config.DB.Where("auto_renew = ? AND status = ?", true, "active").Find(&certs)
+		certs := config.Store.GetActiveWithAutoRenew()
 
 		for _, cert := range certs {
 			if cert.ExpiresAt != nil && time.Until(*cert.ExpiresAt).Hours() < float64(cert.RenewDays*24) {
 				log.Printf("Auto-renewing certificate for %s (expires %s)", cert.Domain, cert.ExpiresAt.Format(time.RFC3339))
-				if err := services.RenewCertificate(&cert, cfg); err != nil {
+				if err := services.RenewCertificate(cert, cfg); err != nil {
 					cert.Status = "error"
 					cert.ErrorMessage = "auto renew: " + err.Error()
 					log.Printf("Auto-renew failed for %s: %v", cert.Domain, err)
 				} else {
 					log.Printf("Auto-renew succeeded for %s", cert.Domain)
 				}
-				config.DB.Save(&cert)
+				config.Store.Upsert(cert)
 			}
 		}
 	})