Files
Auto-ssl/backend/services/acme.go
T

324 lines
9.7 KiB
Go

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 := &registration.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 }