feat(cert): add self-signed certificate renewal worker

Add auto-renewal worker for self-signed certificates that mirrors the
ACME renewal logic, using a dedicated shouldRenewSelfSignedCert threshold
function verified with TDD.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0xJacky
2026-05-18 11:43:54 +08:00
parent ea622d8feb
commit e9cf3278e9
2 changed files with 173 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
package cert
import (
"runtime"
"time"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/0xJacky/Nginx-UI/internal/notification"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/settings"
pkgerrors "github.com/pkg/errors"
"github.com/uozi-tech/cosy/logger"
)
// RenewSelfSignedCerts renews every self-signed certificate that is close to
// expiry. It is invoked by a dedicated cron job.
func RenewSelfSignedCerts() {
defer func() {
if err := recover(); err != nil {
buf := make([]byte, 1024)
runtime.Stack(buf, false)
logger.Errorf("%s\n%s", err, buf)
}
}()
logger.Info("RenewSelfSignedCerts Worker Started")
db := model.UseDB()
if db == nil {
return
}
var certs []*model.Cert
db.Where("auto_cert = ?", model.AutoCertSelfSigned).Find(&certs)
now := time.Now()
renewalInterval := settings.CertSettings.GetCertRenewalInterval()
for _, certModel := range certs {
renewSelfSignedCert(certModel, now, renewalInterval)
}
logger.Info("RenewSelfSignedCerts Worker End")
}
// renewSelfSignedCert renews a single self-signed certificate when it is due.
func renewSelfSignedCert(certModel *model.Cert, now time.Time, renewalInterval int) {
log := NewLogger()
log.SetCertModel(certModel)
defer log.Close()
targetName := getAutoRenewTargetName(certModel)
if shouldSkipAutoRenew(certModel, now) {
logger.Infof("Skip auto renew for %s until %s after previous failure", targetName,
certModel.LastAutoRenewAt.Add(autoRenewFailureRetryCooldown).Format(time.DateTime))
return
}
if certModel.SSLCertificatePath == "" {
handleAutoRenewFailure(certModel, log, targetName,
pkgerrors.New("ssl certificate path is empty for self-signed certificate"))
return
}
info, err := GetCertInfo(certModel.SSLCertificatePath)
if err != nil {
handleAutoRenewFailure(certModel, log, targetName,
pkgerrors.Wrap(err, "get self-signed certificate info error"))
return
}
if !shouldRenewSelfSignedCert(info, now, renewalInterval) {
return
}
certPEM, keyPEM, err := RegenerateSelfSigned(certModel)
if err != nil {
handleAutoRenewFailure(certModel, log, targetName, err)
return
}
content := &Content{
SSLCertificatePath: certModel.SSLCertificatePath,
SSLCertificateKeyPath: certModel.SSLCertificateKeyPath,
SSLCertificate: string(certPEM),
SSLCertificateKey: string(keyPEM),
}
if err = content.WriteFile(); err != nil {
handleAutoRenewFailure(certModel, log, targetName, err)
return
}
nginx.Reload()
updateAutoRenewStatus(certModel, now, "")
notification.Success("Renew Certificate Success",
"Certificate %{name} renewed successfully", map[string]any{"name": targetName})
if err = SyncToRemoteServer(certModel); err != nil {
notification.Error("Sync Certificate Error", err.Error(), nil)
}
}
// shouldRenewSelfSignedCert reports whether a self-signed certificate with the
// given info should be renewed now. It mirrors the renewal-threshold logic of
// the ACME auto-renewal job.
func shouldRenewSelfSignedCert(info *Info, now time.Time, renewalInterval int) bool {
certAge := int(now.Sub(info.NotBefore).Hours() / 24)
daysUntilExpiration := int(info.NotAfter.Sub(now).Hours() / 24)
totalValidityDays := int(info.NotAfter.Sub(info.NotBefore).Hours() / 24)
if totalValidityDays < renewalInterval {
// short-lived certificate: renew once 2/3 of the lifetime has elapsed
earlyRenewalThreshold := 2 * totalValidityDays / 3
return daysUntilExpiration <= earlyRenewalThreshold
}
// normal certificate: renew once the age reaches the renewal interval
return certAge >= renewalInterval
}
@@ -0,0 +1,56 @@
package cert
import (
"testing"
"time"
)
func TestShouldRenewSelfSignedCert(t *testing.T) {
now := time.Date(2026, time.May, 18, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
notBefore time.Time
notAfter time.Time
renewalInterval int
expected bool
}{
{
name: "normal cert young enough is not renewed",
notBefore: now.AddDate(0, 0, -3),
notAfter: now.AddDate(0, 0, 362),
renewalInterval: 7,
expected: false,
},
{
name: "normal cert past renewal interval is renewed",
notBefore: now.AddDate(0, 0, -10),
notAfter: now.AddDate(0, 0, 355),
renewalInterval: 7,
expected: true,
},
{
name: "short-lived cert with plenty of life left is not renewed",
notBefore: now.AddDate(0, 0, -1),
notAfter: now.AddDate(0, 0, 4),
renewalInterval: 7,
expected: false,
},
{
name: "short-lived cert near expiry is renewed",
notBefore: now.AddDate(0, 0, -4),
notAfter: now.AddDate(0, 0, 1),
renewalInterval: 7,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &Info{NotBefore: tt.notBefore, NotAfter: tt.notAfter}
if got := shouldRenewSelfSignedCert(info, now, tt.renewalInterval); got != tt.expected {
t.Fatalf("shouldRenewSelfSignedCert() = %v, want %v", got, tt.expected)
}
})
}
}