mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
fix(cert): throttle auto-renew retries and expose renewal errors
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user