fix(cert): throttle auto-renew retries and expose renewal errors

This commit is contained in:
0xJacky
2026-04-19 12:23:52 +08:00
parent 05e544c8f5
commit 899c9f1995
6 changed files with 254 additions and 36 deletions
+7 -1
View File
@@ -1,10 +1,16 @@
import type { ModelBase } from '@/api/curd'
import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
export interface NotificationDetails {
response?: string | Record<string, unknown>
[key: string]: unknown
}
export interface Notification extends ModelBase {
type: string
title: string
details: string
content: string
details: string | NotificationDetails | null
}
const baseUrl = '/notifications'
@@ -6,6 +6,19 @@ import { NotificationTypeT } from '@/constants'
import { translateError } from '@/lib/http/error'
import notifications from './notifications'
function parseResponsePayload(response: string | object): string | object {
if (typeof response !== 'string') {
return response
}
try {
return JSON.parse(response) as object
}
catch {
return response
}
}
// Helper function to parse and translate error
async function parseError(response: string): Promise<string | null> {
try {
@@ -14,8 +27,7 @@ async function parseError(response: string): Promise<string | null> {
return await translateError(errorData)
}
}
catch (error) {
console.error('Failed to parse error response:', error)
catch {
}
return null
}
@@ -32,12 +44,10 @@ const ErrorDetails = defineComponent({
const translatedError = ref<string>('')
const isLoading = ref(true)
// Convert response to string if it's an object
const responseString = typeof props.response === 'string'
? props.response
: JSON.stringify(props.response)
// Immediately start translation
parseError(responseString).then(result => {
if (result) {
translatedError.value = result
@@ -46,33 +56,30 @@ const ErrorDetails = defineComponent({
})
return () => {
const parsedResponse = typeof props.response === 'string'
? JSON.parse(props.response)
: props.response
const parsedResponse = parseResponsePayload(props.response)
return (
<div class="mt-2">
{/* 显示翻译后的错误信息(如果有) */}
{translatedError.value && (
<div class="text-red-500 font-medium mb-2">
{translatedError.value}
</div>
)}
{/* 显示翻译状态 */}
{isLoading.value && (
<div class="text-gray-500 text-sm mb-2">
{$gettext('Translating error...')}
</div>
)}
{/* 默认显示原始错误信息 */}
<details class="mt-2">
<summary class="cursor-pointer text-sm text-gray-600 hover:text-gray-800">
{$gettext('Error details')}
</summary>
<pre class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-hidden whitespace-pre-wrap break-words max-w-full">
{JSON.stringify(parsedResponse, null, 2)}
{typeof parsedResponse === 'string'
? parsedResponse
: JSON.stringify(parsedResponse, null, 2)}
</pre>
</details>
</div>
@@ -37,6 +37,14 @@ const notifications: Record<string, { title: () => string, content: (args: any)
title: () => $gettext('Auto Backup Completed'),
content: (args: any) => $gettext('Backup task %{backup_name} completed successfully, file: %{file_path}', args, true),
},
'Renew Certificate Success': {
title: () => $gettext('Renew Certificate Success'),
content: (args: any) => $gettext('Certificate %{name} renewed successfully', args, true),
},
'Renew Certificate Error': {
title: () => $gettext('Renew Certificate Error'),
content: (args: any) => $gettext('Certificate %{name} renewal failed: %{error}', args, true),
},
'Certificate Expired': {
title: () => $gettext('Certificate Expired'),
content: (args: any) => $gettext('Certificate %{name} has expired', args, true),
+118 -24
View File
@@ -1,6 +1,7 @@
package cert
import (
stderrors "errors"
"runtime"
"strings"
"time"
@@ -8,10 +9,15 @@ import (
"github.com/0xJacky/Nginx-UI/internal/notification"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/pkg/errors"
pkgerrors "github.com/pkg/errors"
"github.com/uozi-tech/cosy"
"github.com/uozi-tech/cosy/logger"
)
const (
autoRenewFailureRetryCooldown = 12 * time.Hour
)
func AutoCert() {
defer func() {
if err := recover(); err != nil {
@@ -29,36 +35,39 @@ func AutoCert() {
}
func autoCert(certModel *model.Cert) {
confName := certModel.Filename
log := NewLogger()
log.SetCertModel(certModel)
defer log.Close()
targetName := getAutoRenewTargetName(certModel)
now := time.Now()
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 len(certModel.Filename) == 0 {
log.Error(ErrCertModelFilenameEmpty)
handleAutoRenewFailure(certModel, log, targetName, ErrCertModelFilenameEmpty)
return
}
if len(certModel.Domains) == 0 {
log.Error(errors.New("domains list is empty, " +
"try to reopen auto-cert for this config:" + confName))
notification.Error("Renew Certificate Error", confName, nil)
handleAutoRenewFailure(certModel, log, targetName,
pkgerrors.New("domains list is empty, try to reopen auto-cert for this config:"+certModel.Filename))
return
}
if certModel.SSLCertificatePath == "" {
log.Error(errors.New("ssl certificate path is empty, " +
"try to reopen auto-cert for this config:" + confName))
notification.Error("Renew Certificate Error", confName, nil)
handleAutoRenewFailure(certModel, log, targetName,
pkgerrors.New("ssl certificate path is empty, try to reopen auto-cert for this config:"+certModel.Filename))
return
}
certInfo, err := GetCertInfo(certModel.SSLCertificatePath)
if err != nil {
// Get certificate info error, ignore this certificate
log.Error(errors.Wrap(err, "get certificate info error"))
notification.Error("Renew Certificate Error", strings.Join(certModel.Domains, ", "), nil)
handleAutoRenewFailure(certModel, log, targetName, pkgerrors.Wrap(err, "get certificate info error"))
return
}
@@ -72,26 +81,21 @@ func autoCert(certModel *model.Cert) {
renewalInterval := settings.CertSettings.GetCertRenewalInterval()
// For certificates with short validity periods (less than renewal interval),
// use early renewal logic to prevent expiration
// use early renewal logic to prevent expiration.
if totalValidityDays < renewalInterval {
// Renew when 2/3 of the certificate's lifetime remains
// This provides a safety buffer for short-lived certificates
// Renew when 2/3 of the certificate's lifetime remains.
earlyRenewalThreshold := 2 * totalValidityDays / 3
if daysUntilExpiration > earlyRenewalThreshold {
return
}
// If we reach here, proceed with renewal for short-lived certificate
} else {
// For normal certificates with validity >= renewal interval:
// Skip renewal if certificate age is less than the configured renewal interval
// This ensures we don't renew certificates too frequently
// skip renewal if certificate age is less than the configured renewal interval.
if certAge < renewalInterval {
return
}
}
// after 1 mo, reissue certificate
// support SAN certification
payload := &ConfigPayload{
CertID: certModel.ID,
ServerName: certModel.Domains,
@@ -117,15 +121,105 @@ func autoCert(certModel *model.Cert) {
err = IssueCert(payload, log)
if err != nil {
log.Error(err)
notification.Error("Renew Certificate Error", strings.Join(payload.ServerName, ", "), nil)
handleAutoRenewFailure(certModel, log, targetName, err)
return
}
notification.Success("Renew Certificate Success", strings.Join(payload.ServerName, ", "), nil)
updateAutoRenewStatus(certModel, now, "")
notification.Success("Renew Certificate Success", "Certificate %{name} renewed successfully", map[string]any{
"name": targetName,
})
err = SyncToRemoteServer(certModel)
if err != nil {
notification.Error("Sync Certificate Error", err.Error(), nil)
return
}
}
func shouldSkipAutoRenew(certModel *model.Cert, now time.Time) bool {
if certModel == nil || certModel.LastAutoRenewAt == nil || certModel.LastAutoRenewError == "" {
return false
}
return now.Before(certModel.LastAutoRenewAt.Add(autoRenewFailureRetryCooldown))
}
func handleAutoRenewFailure(certModel *model.Cert, log *Logger, name string, err error) {
log.Error(err)
updateAutoRenewStatus(certModel, time.Now(), err.Error())
notification.Error("Renew Certificate Error", "Certificate %{name} renewal failed: %{error}",
buildAutoRenewNotificationDetails(name, err))
}
func updateAutoRenewStatus(certModel *model.Cert, at time.Time, renewalError string) {
if certModel == nil {
return
}
certModel.LastAutoRenewAt = &at
certModel.LastAutoRenewError = renewalError
db := model.UseDB()
if db == nil || certModel.ID == 0 {
return
}
err := db.Model(&model.Cert{}).
Where("id = ?", certModel.ID).
Updates(map[string]any{
"last_auto_renew_at": at,
"last_auto_renew_error": renewalError,
}).Error
if err != nil {
logger.Error(err)
}
}
func buildAutoRenewNotificationDetails(name string, err error) map[string]any {
details := map[string]any{
"name": name,
}
if err == nil {
return details
}
details["error"] = strings.TrimSpace(err.Error())
details["response"] = getAutoRenewNotificationResponse(err)
return details
}
func getAutoRenewNotificationResponse(err error) any {
if err == nil {
return nil
}
var cosyErr *cosy.Error
if stderrors.As(err, &cosyErr) {
return cosyErr
}
return strings.TrimSpace(err.Error())
}
func getAutoRenewTargetName(certModel *model.Cert) string {
if certModel == nil {
return "unknown certificate"
}
if len(certModel.Domains) > 0 {
return strings.Join(certModel.Domains, ", ")
}
if certModel.Filename != "" {
return certModel.Filename
}
if certModel.Name != "" {
return certModel.Name
}
return "unknown certificate"
}
+100
View File
@@ -0,0 +1,100 @@
package cert
import (
stderrors "errors"
"testing"
"time"
"github.com/0xJacky/Nginx-UI/model"
"github.com/uozi-tech/cosy"
)
func TestShouldSkipAutoRenew(t *testing.T) {
now := time.Date(2026, time.April, 19, 12, 0, 0, 0, time.UTC)
recentFailureAt := now.Add(-11 * time.Hour)
expiredFailureAt := now.Add(-13 * time.Hour)
tests := []struct {
name string
cert *model.Cert
expected bool
}{
{
name: "skip recent failed renewal",
cert: &model.Cert{
LastAutoRenewAt: &recentFailureAt,
LastAutoRenewError: "challenge error",
},
expected: true,
},
{
name: "retry after cooldown window",
cert: &model.Cert{
LastAutoRenewAt: &expiredFailureAt,
LastAutoRenewError: "challenge error",
},
expected: false,
},
{
name: "do not skip successful renewal state",
cert: &model.Cert{
LastAutoRenewAt: &recentFailureAt,
LastAutoRenewError: "",
},
expected: false,
},
{
name: "do not skip without attempt timestamp",
cert: &model.Cert{
LastAutoRenewError: "challenge error",
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldSkipAutoRenew(tt.cert, now); got != tt.expected {
t.Fatalf("shouldSkipAutoRenew() = %v, want %v", got, tt.expected)
}
})
}
}
func TestBuildAutoRenewNotificationDetails(t *testing.T) {
err := cosy.WrapErrorWithParams(ErrRenewCert, "dns token invalid")
details := buildAutoRenewNotificationDetails("example.com", err)
if got := details["name"]; got != "example.com" {
t.Fatalf("unexpected name: %v", got)
}
if got := details["error"]; got != err.Error() {
t.Fatalf("unexpected error text: %v", got)
}
response, ok := details["response"].(*cosy.Error)
if !ok {
t.Fatalf("unexpected response type: %T", details["response"])
}
if response.Scope != "cert" || response.Code != 50018 {
t.Fatalf("unexpected cosy error payload: %+v", response)
}
}
func TestGetAutoRenewNotificationResponseFallsBackToPlainText(t *testing.T) {
err := stderrors.New("plain failure")
response := getAutoRenewNotificationResponse(err)
text, ok := response.(string)
if !ok {
t.Fatalf("unexpected response type: %T", response)
}
if text != "plain failure" {
t.Fatalf("unexpected fallback response: %s", text)
}
}
+3
View File
@@ -2,6 +2,7 @@ package model
import (
"os"
"time"
"github.com/0xJacky/Nginx-UI/internal/helper"
"github.com/0xJacky/Nginx-UI/internal/nginx"
@@ -48,6 +49,8 @@ type Cert struct {
MustStaple bool `json:"must_staple"`
LegoDisableCNAMESupport bool `json:"lego_disable_cname_support"`
RevokeOld bool `json:"revoke_old"`
LastAutoRenewAt *time.Time `json:"-"`
LastAutoRenewError string `json:"-"`
}
func FirstCert(confName string) (c Cert, err error) {