feat: AutoSSL certificate management tool with Web UI
This commit is contained in:
@@ -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 }
|
||||
Reference in New Issue
Block a user