|
|
@@ -0,0 +1,323 @@
|
|
|
+package services
|
|
|
+
|
|
|
+import (
|
|
|
+ "auto-ssl/config"
|
|
|
+ "crypto"
|
|
|
+ "crypto/ecdsa"
|
|
|
+ "crypto/elliptic"
|
|
|
+ "crypto/rand"
|
|
|
+ "crypto/x509"
|
|
|
+ "encoding/json"
|
|
|
+ "encoding/pem"
|
|
|
+ "fmt"
|
|
|
+ "log"
|
|
|
+ "os"
|
|
|
+ "path/filepath"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "github.com/go-acme/lego/v4/certcrypto"
|
|
|
+ "github.com/go-acme/lego/v4/certificate"
|
|
|
+ "github.com/go-acme/lego/v4/challenge"
|
|
|
+ "github.com/go-acme/lego/v4/challenge/http01"
|
|
|
+ "github.com/go-acme/lego/v4/challenge/dns01"
|
|
|
+ "github.com/go-acme/lego/v4/lego"
|
|
|
+ alidnsprov "github.com/go-acme/lego/v4/providers/dns/alidns"
|
|
|
+ cloudflareprov "github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
|
|
+ dnspodprov "github.com/go-acme/lego/v4/providers/dns/dnspod"
|
|
|
+ "github.com/go-acme/lego/v4/registration"
|
|
|
+)
|
|
|
+
|
|
|
+type ACMEAccount struct {
|
|
|
+ Email string
|
|
|
+ PrivateKey crypto.PrivateKey
|
|
|
+ Registration *registration.Resource
|
|
|
+}
|
|
|
+
|
|
|
+// DNSConfig represents DNS provider configuration
|
|
|
+type DNSConfig struct {
|
|
|
+ Provider string `json:"provider"`
|
|
|
+
|
|
|
+ // Aliyun DNS
|
|
|
+ AliKey string `json:"ali_key,omitempty"`
|
|
|
+ AliSecret string `json:"ali_secret,omitempty"`
|
|
|
+
|
|
|
+ // Cloudflare
|
|
|
+ CFAPIToken string `json:"cf_api_token,omitempty"`
|
|
|
+
|
|
|
+ // DNSPod
|
|
|
+ DNSPodID string `json:"dnspod_id,omitempty"`
|
|
|
+ DNSPodKey string `json:"dnspod_key,omitempty"`
|
|
|
+}
|
|
|
+
|
|
|
+// GetACMECertificate obtains a certificate from ACME provider
|
|
|
+func GetACMECertificate(cert *config.Certificate, cfg *config.Config) error {
|
|
|
+ dir := filepath.Join(cfg.AccountsDir, sanitizeEmail(cert.Email))
|
|
|
+
|
|
|
+ account, err := getOrCreateAccount(cert.Email, cert.Provider, dir)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("failed to setup ACME account: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ legoCfg := lego.NewConfig(account)
|
|
|
+ legoCfg.CADirURL = getCADirURL(cert.Provider)
|
|
|
+ legoCfg.Certificate.KeyType = certcrypto.RSA2048
|
|
|
+
|
|
|
+ client, err := lego.NewClient(legoCfg)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("failed to create lego client: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if strings.ToLower(cert.ChallengeType) == "dns" {
|
|
|
+ provider, err := getDNSProvider(cert)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("failed to create DNS provider: %v", err)
|
|
|
+ }
|
|
|
+ if err := client.Challenge.SetDNS01Provider(provider,
|
|
|
+ dns01.AddRecursiveNameservers(dns01.ParseNameservers([]string{"8.8.8.8:53", "1.1.1.1:53"})),
|
|
|
+ ); err != nil {
|
|
|
+ return fmt.Errorf("failed to set DNS-01 provider: %v", err)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if err := client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "80")); err != nil {
|
|
|
+ return fmt.Errorf("failed to set HTTP-01 provider: %v", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ request := certificate.ObtainRequest{
|
|
|
+ Domains: []string{cert.Domain},
|
|
|
+ Bundle: true,
|
|
|
+ MustStaple: false,
|
|
|
+ }
|
|
|
+ certRes, err := client.Certificate.Obtain(request)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("failed to obtain certificate: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Save certificate files
|
|
|
+ certDir := filepath.Join(cfg.CertDir, sanitizeDomain(cert.Domain))
|
|
|
+ if err := os.MkdirAll(certDir, 0700); err != nil {
|
|
|
+ return fmt.Errorf("failed to create cert directory: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ os.WriteFile(filepath.Join(certDir, "fullchain.pem"), certRes.Certificate, 0644)
|
|
|
+ os.WriteFile(filepath.Join(certDir, "privkey.pem"), certRes.PrivateKey, 0600)
|
|
|
+ os.WriteFile(filepath.Join(certDir, "chain.pem"), certRes.IssuerCertificate, 0644)
|
|
|
+
|
|
|
+ now := time.Now()
|
|
|
+ expiresAt := parseCertExpiry(certRes.Certificate)
|
|
|
+ cert.Status = "active"
|
|
|
+ cert.CertURL = certRes.CertURL
|
|
|
+ cert.ExpiresAt = expiresAt
|
|
|
+ cert.LastRenewedAt = &now
|
|
|
+ cert.ErrorMessage = ""
|
|
|
+
|
|
|
+ log.Printf("Certificate obtained successfully for %s, expires at %s", cert.Domain, expiresAt.Format(time.RFC3339))
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// RenewCertificate renews an existing certificate
|
|
|
+func RenewCertificate(cert *config.Certificate, cfg *config.Config) error {
|
|
|
+ dir := filepath.Join(cfg.AccountsDir, sanitizeEmail(cert.Email))
|
|
|
+
|
|
|
+ account, err := getOrCreateAccount(cert.Email, cert.Provider, dir)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("failed to setup ACME account: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ legoCfg := lego.NewConfig(account)
|
|
|
+ legoCfg.CADirURL = getCADirURL(cert.Provider)
|
|
|
+ legoCfg.Certificate.KeyType = certcrypto.RSA2048
|
|
|
+
|
|
|
+ client, err := lego.NewClient(legoCfg)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("failed to create lego client: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ if strings.ToLower(cert.ChallengeType) == "dns" {
|
|
|
+ provider, err := getDNSProvider(cert)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("failed to create DNS provider: %v", err)
|
|
|
+ }
|
|
|
+ if err := client.Challenge.SetDNS01Provider(provider,
|
|
|
+ dns01.AddRecursiveNameservers(dns01.ParseNameservers([]string{"8.8.8.8:53", "1.1.1.1:53"})),
|
|
|
+ ); err != nil {
|
|
|
+ return fmt.Errorf("failed to set DNS-01 provider: %v", err)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if err := client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "80")); err != nil {
|
|
|
+ return fmt.Errorf("failed to set HTTP-01 provider: %v", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ certRes, err := client.Certificate.Renew(certificate.Resource{
|
|
|
+ Domain: cert.Domain,
|
|
|
+ CertURL: cert.CertURL,
|
|
|
+ PrivateKey: nil,
|
|
|
+ Certificate: nil,
|
|
|
+ }, true, false, "")
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("failed to renew certificate: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ certDir := filepath.Join(cfg.CertDir, sanitizeDomain(cert.Domain))
|
|
|
+ os.MkdirAll(certDir, 0700)
|
|
|
+ os.WriteFile(filepath.Join(certDir, "fullchain.pem"), certRes.Certificate, 0644)
|
|
|
+ os.WriteFile(filepath.Join(certDir, "privkey.pem"), certRes.PrivateKey, 0600)
|
|
|
+ os.WriteFile(filepath.Join(certDir, "chain.pem"), certRes.IssuerCertificate, 0644)
|
|
|
+
|
|
|
+ now := time.Now()
|
|
|
+ expiresAt := parseCertExpiry(certRes.Certificate)
|
|
|
+ cert.Status = "active"
|
|
|
+ cert.CertURL = certRes.CertURL
|
|
|
+ cert.ExpiresAt = expiresAt
|
|
|
+ cert.LastRenewedAt = &now
|
|
|
+ cert.ErrorMessage = ""
|
|
|
+
|
|
|
+ log.Printf("Certificate renewed successfully for %s, expires at %s", cert.Domain, expiresAt.Format(time.RFC3339))
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// GetCertFilesPaths returns paths to certificate files
|
|
|
+func GetCertFilesPaths(domain string, cfg *config.Config) (fullchain, privkey, chain string) {
|
|
|
+ dir := filepath.Join(cfg.CertDir, sanitizeDomain(domain))
|
|
|
+ return filepath.Join(dir, "fullchain.pem"),
|
|
|
+ filepath.Join(dir, "privkey.pem"),
|
|
|
+ filepath.Join(dir, "chain.pem")
|
|
|
+}
|
|
|
+
|
|
|
+func getOrCreateAccount(email, provider, dir string) (*ACMEAccount, error) {
|
|
|
+ keyFile := filepath.Join(dir, "account.key")
|
|
|
+ regFile := filepath.Join(dir, "registration.json")
|
|
|
+ os.MkdirAll(dir, 0700)
|
|
|
+
|
|
|
+ // Try to load existing account
|
|
|
+ if data, err := os.ReadFile(keyFile); err == nil {
|
|
|
+ block, _ := pem.Decode(data)
|
|
|
+ if block != nil {
|
|
|
+ key, err := x509.ParseECPrivateKey(block.Bytes)
|
|
|
+ if err == nil {
|
|
|
+ reg := ®istration.Resource{}
|
|
|
+ if regData, err := os.ReadFile(regFile); err == nil {
|
|
|
+ json.Unmarshal(regData, reg)
|
|
|
+ }
|
|
|
+ return &ACMEAccount{Email: email, PrivateKey: key, Registration: reg}, nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Create new account
|
|
|
+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to generate private key: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ account := &ACMEAccount{Email: email, PrivateKey: privateKey}
|
|
|
+
|
|
|
+ legoCfg := lego.NewConfig(account)
|
|
|
+ legoCfg.CADirURL = getCADirURL(provider)
|
|
|
+ legoCfg.Certificate.KeyType = certcrypto.RSA2048
|
|
|
+
|
|
|
+ client, err := lego.NewClient(legoCfg)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to create lego client: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ reg, err := client.Registration.Register(registration.RegisterOptions{
|
|
|
+ TermsOfServiceAgreed: true,
|
|
|
+ })
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to register ACME account: %v", err)
|
|
|
+ }
|
|
|
+ account.Registration = reg
|
|
|
+
|
|
|
+ keyData, _ := x509.MarshalECPrivateKey(privateKey)
|
|
|
+ pemData := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyData})
|
|
|
+ os.WriteFile(keyFile, pemData, 0600)
|
|
|
+
|
|
|
+ regData, _ := json.MarshalIndent(reg, "", " ")
|
|
|
+ os.WriteFile(regFile, regData, 0600)
|
|
|
+
|
|
|
+ log.Printf("New ACME account created for %s with %s", email, provider)
|
|
|
+ return account, nil
|
|
|
+}
|
|
|
+
|
|
|
+func getCADirURL(provider string) string {
|
|
|
+ switch strings.ToLower(provider) {
|
|
|
+ case "zerossl":
|
|
|
+ return "https://acme.zerossl.com/v2/DV90"
|
|
|
+ default:
|
|
|
+ return "https://acme-v02.api.letsencrypt.org/directory"
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func getDNSProvider(cert *config.Certificate) (challenge.Provider, error) {
|
|
|
+ var dnsCfg DNSConfig
|
|
|
+ if cert.DNSConfig != "" {
|
|
|
+ if err := json.Unmarshal([]byte(cert.DNSConfig), &dnsCfg); err != nil {
|
|
|
+ return nil, fmt.Errorf("invalid DNS config JSON: %v", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ switch strings.ToLower(cert.DNSProvider) {
|
|
|
+ case "alidns", "aliyun":
|
|
|
+ cfg := alidnsprov.NewDefaultConfig()
|
|
|
+ cfg.APIKey = dnsCfg.AliKey
|
|
|
+ cfg.SecretKey = dnsCfg.AliSecret
|
|
|
+ provider, err := alidnsprov.NewDNSProviderConfig(cfg)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to create Aliyun DNS provider: %v", err)
|
|
|
+ }
|
|
|
+ return provider, nil
|
|
|
+
|
|
|
+ case "cloudflare":
|
|
|
+ cfg := cloudflareprov.NewDefaultConfig()
|
|
|
+ if dnsCfg.CFAPIToken != "" {
|
|
|
+ cfg.AuthToken = dnsCfg.CFAPIToken
|
|
|
+ }
|
|
|
+ provider, err := cloudflareprov.NewDNSProviderConfig(cfg)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to create Cloudflare DNS provider: %v", err)
|
|
|
+ }
|
|
|
+ return provider, nil
|
|
|
+
|
|
|
+ case "dnspod":
|
|
|
+ cfg := dnspodprov.NewDefaultConfig()
|
|
|
+ if dnsCfg.DNSPodID != "" && dnsCfg.DNSPodKey != "" {
|
|
|
+ cfg.LoginToken = dnsCfg.DNSPodID + "," + dnsCfg.DNSPodKey
|
|
|
+ }
|
|
|
+ provider, err := dnspodprov.NewDNSProviderConfig(cfg)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("failed to create DNSPod DNS provider: %v", err)
|
|
|
+ }
|
|
|
+ return provider, nil
|
|
|
+
|
|
|
+ default:
|
|
|
+ return nil, fmt.Errorf("unsupported DNS provider: %s", dnsCfg.Provider)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func parseCertExpiry(certPEM []byte) *time.Time {
|
|
|
+ block, _ := pem.Decode(certPEM)
|
|
|
+ if block == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ cert, err := x509.ParseCertificate(block.Bytes)
|
|
|
+ if err != nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ return &cert.NotAfter
|
|
|
+}
|
|
|
+
|
|
|
+func sanitizeEmail(email string) string {
|
|
|
+ return strings.NewReplacer("@", "_at_", ".", "_dot_").Replace(email)
|
|
|
+}
|
|
|
+
|
|
|
+func sanitizeDomain(domain string) string {
|
|
|
+ return strings.NewReplacer("*", "wildcard_", ".", "_").Replace(domain)
|
|
|
+}
|
|
|
+
|
|
|
+// lego User interface implementation
|
|
|
+func (a *ACMEAccount) GetEmail() string { return a.Email }
|
|
|
+func (a *ACMEAccount) GetRegistration() *registration.Resource { return a.Registration }
|
|
|
+func (a *ACMEAccount) GetPrivateKey() crypto.PrivateKey { return a.PrivateKey }
|