From 978c9b2fc39409b100cbe5ac81970d64c03a0b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Thu, 8 Jan 2026 22:09:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20acme=20ari=E6=94=AF=E6=8C=81=EF=BC=8Ccl?= =?UTF-8?q?ose=20#1192?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/cert.go | 29 ++++++++++++++++------------- internal/data/cert.go | 38 ++++++++++++++++++++++++++++++++++---- internal/job/cert_renew.go | 25 ++++++++++++++----------- pkg/acme/client.go | 10 ++++++++-- 4 files changed, 72 insertions(+), 30 deletions(-) diff --git a/internal/biz/cert.go b/internal/biz/cert.go index 841d8395..087a1022 100644 --- a/internal/biz/cert.go +++ b/internal/biz/cert.go @@ -6,22 +6,24 @@ import ( "github.com/acepanel/panel/internal/http/request" "github.com/acepanel/panel/pkg/acme" "github.com/acepanel/panel/pkg/types" + mholtacme "github.com/mholt/acmez/v3/acme" ) type Cert struct { - ID uint `gorm:"primaryKey" json:"id"` - AccountID uint `gorm:"not null;default:0" json:"account_id"` // 关联的 ACME 账户 ID - WebsiteID uint `gorm:"not null;default:0" json:"website_id"` // 关联的网站 ID - DNSID uint `gorm:"not null;default:0" json:"dns_id"` // 关联的 DNS ID - Type string `gorm:"not null;default:''" json:"type"` // 证书类型 (P256, P384, 2048, 3072, 4096) - Domains []string `gorm:"not null;default:'[]';serializer:json" json:"domains"` - AutoRenew bool `gorm:"not null;default:false" json:"auto_renew"` // 自动续签 - CertURL string `gorm:"not null;default:''" json:"cert_url"` // 证书 URL (续签时使用) - Cert string `gorm:"not null;default:''" json:"cert"` // 证书内容 - Key string `gorm:"not null;default:''" json:"key"` // 私钥内容 - Script string `gorm:"not null;default:''" json:"script"` // 部署脚本 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + AccountID uint `gorm:"not null;default:0" json:"account_id"` // 关联的 ACME 账户 ID + WebsiteID uint `gorm:"not null;default:0" json:"website_id"` // 关联的网站 ID + DNSID uint `gorm:"not null;default:0" json:"dns_id"` // 关联的 DNS ID + Type string `gorm:"not null;default:''" json:"type"` // 证书类型 (P256, P384, 2048, 3072, 4096) + Domains []string `gorm:"not null;default:'[]';serializer:json" json:"domains"` + AutoRenew bool `gorm:"not null;default:false" json:"auto_renew"` // 自动续签 + RenewalInfo mholtacme.RenewalInfo `gorm:"not null;default:'{}';serializer:json" json:"renewal_info"` // 续签信息 + CertURL string `gorm:"not null;default:''" json:"cert_url"` // 证书 URL (续签时使用) + Cert string `gorm:"not null;default:''" json:"cert"` // 证书内容 + Key string `gorm:"not null;default:''" json:"key"` // 私钥内容 + Script string `gorm:"not null;default:''" json:"script"` // 部署脚本 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Website *Website `gorm:"foreignKey:WebsiteID" json:"website"` Account *CertAccount `gorm:"foreignKey:AccountID" json:"account"` @@ -41,6 +43,7 @@ type CertRepo interface { ObtainPanel(account *CertAccount, ips []string) ([]byte, []byte, error) ObtainSelfSigned(id uint) error Renew(id uint) (*acme.Certificate, error) + RefreshRenewalInfo(id uint) (mholtacme.RenewalInfo, error) ManualDNS(id uint) ([]acme.DNSRecord, error) Deploy(ID, WebsiteID uint) error } diff --git a/internal/data/cert.go b/internal/data/cert.go index e6e59040..1ae8a6d3 100644 --- a/internal/data/cert.go +++ b/internal/data/cert.go @@ -12,6 +12,7 @@ import ( "time" "github.com/leonelquinteros/gotext" + mholtacme "github.com/mholt/acmez/v3/acme" "gorm.io/gorm" "github.com/acepanel/panel/internal/app" @@ -183,6 +184,7 @@ func (r *certRepo) ObtainAuto(id uint) (*acme.Certificate, error) { return nil, err } + cert.RenewalInfo = *ssl.RenewalInfo cert.CertURL = ssl.URL cert.Cert = string(ssl.ChainPEM) cert.Key = string(ssl.PrivateKey) @@ -216,6 +218,7 @@ func (r *certRepo) ObtainManual(id uint) (*acme.Certificate, error) { return nil, err } + cert.RenewalInfo = *ssl.RenewalInfo cert.CertURL = ssl.URL cert.Cert = string(ssl.ChainPEM) cert.Key = string(ssl.PrivateKey) @@ -241,7 +244,7 @@ func (r *certRepo) ObtainPanel(account *biz.CertAccount, ips []string) ([]byte, } client.UsePanel(ips, filepath.Join(app.Root, "server/nginx/conf/acme.conf")) - ssl, err := client.ObtainShortCertificate(context.Background(), ips, acme.KeyEC256) + ssl, err := client.ObtainIPCertificate(context.Background(), ips, acme.KeyEC256) if err != nil { return nil, nil, err } @@ -317,6 +320,7 @@ func (r *certRepo) Renew(id uint) (*acme.Certificate, error) { } } + cert.RenewalInfo = *ssl.RenewalInfo cert.CertURL = ssl.URL cert.Cert = string(ssl.ChainPEM) cert.Key = string(ssl.PrivateKey) @@ -331,6 +335,34 @@ func (r *certRepo) Renew(id uint) (*acme.Certificate, error) { return &ssl, nil } +func (r *certRepo) RefreshRenewalInfo(id uint) (mholtacme.RenewalInfo, error) { + cert, err := r.Get(id) + if err != nil { + return mholtacme.RenewalInfo{}, err + } + client, err := r.getClient(cert) + if err != nil { + return mholtacme.RenewalInfo{}, err + } + + crt, err := pkgcert.ParseCert(cert.Cert) + if err != nil { + return mholtacme.RenewalInfo{}, err + } + + renewInfo, err := client.GetRenewalInfo(context.Background(), crt) + if err != nil { + return mholtacme.RenewalInfo{}, err + } + + cert.RenewalInfo = renewInfo + if err = r.db.Save(cert).Error; err != nil { + return mholtacme.RenewalInfo{}, err + } + + return renewInfo, nil +} + func (r *certRepo) ManualDNS(id uint) ([]acme.DNSRecord, error) { cert, err := r.Get(id) if err != nil { @@ -409,9 +441,7 @@ func (r *certRepo) runScript(cert *biz.Cert) error { if err = f.Close(); err != nil { return err } - defer func(name string) { - _ = os.Remove(name) - }(f.Name()) + defer func(name string) { _ = os.Remove(name) }(f.Name()) _, err = shell.Execf("bash " + f.Name()) return err diff --git a/internal/job/cert_renew.go b/internal/job/cert_renew.go index 6fa8b2e7..23ada2cf 100644 --- a/internal/job/cert_renew.go +++ b/internal/job/cert_renew.go @@ -49,23 +49,26 @@ func (r *CertRenew) Run() { } for _, cert := range certs { + // 跳过上传类型或未开启自动续签的证书 if cert.Type == "upload" || !cert.AutoRenew { continue } - decode, err := pkgcert.ParseCert(cert.Cert) - if err != nil { - continue + // 刷新续签信息 + if cert.RenewalInfo.NeedsRefresh() { + renewInfo, err := r.certRepo.RefreshRenewalInfo(cert.ID) + if err != nil { + r.log.Warn("[CertRenew] failed to refresh renewal info", slog.Any("err", err)) + continue + } + cert.RenewalInfo = renewInfo } - // 结束时间大于 7 天的证书不续签 - if time.Until(decode.NotAfter) > 24*7*time.Hour { - continue - } - - _, err = r.certRepo.Renew(cert.ID) - if err != nil { - r.log.Warn("[CertRenew] failed to renew cert", slog.Any("err", err)) + // 到达建议时间,续签证书 + if time.Now().After(cert.RenewalInfo.SelectedTime) { + if _, err := r.certRepo.Renew(cert.ID); err != nil { + r.log.Warn("[CertRenew] failed to renew cert", slog.Any("err", err)) + } } } diff --git a/pkg/acme/client.go b/pkg/acme/client.go index a92c922a..01159bc3 100644 --- a/pkg/acme/client.go +++ b/pkg/acme/client.go @@ -2,6 +2,7 @@ package acme import ( "context" + "crypto/x509" "sort" "github.com/libdns/libdns" @@ -92,8 +93,8 @@ func (c *Client) ObtainCertificate(ctx context.Context, sans []string, keyType K return Certificate{PrivateKey: pemPrivateKey, Certificate: crt}, nil } -// ObtainShortCertificate 签发短期 SSL 证书 -func (c *Client) ObtainShortCertificate(ctx context.Context, sans []string, keyType KeyType) (Certificate, error) { +// ObtainIPCertificate 签发 IP SSL 证书 +func (c *Client) ObtainIPCertificate(ctx context.Context, sans []string, keyType KeyType) (Certificate, error) { certPrivateKey, err := generatePrivateKey(keyType) if err != nil { return Certificate{}, err @@ -174,6 +175,11 @@ func (c *Client) GetDNSRecords(ctx context.Context, domains []string, keyType Ke return data.([]DNSRecord), nil } +// GetRenewalInfo 获取续签建议 +func (c *Client) GetRenewalInfo(ctx context.Context, cert x509.Certificate) (acme.RenewalInfo, error) { + return c.zClient.GetRenewalInfo(ctx, &cert) +} + func (c *Client) selectPreferredChain(certChains []acme.Certificate) acme.Certificate { if len(certChains) == 1 { return certChains[0]