feat(cert): preserve config and add retry on issuance failure (#1694)

* feat(cert): add Status, LastError, LastAttemptAt fields

* feat(cert): sweep stale pending certs at startup

* feat(cert): invoke SweepStalePending at cron startup

* feat(cert): skip non-success status in auto-renew worker

* feat(cert): persist draft on issuance entry, status transitions on completion

* feat(cert): expose status, last_error, last_attempt_at on Cert type

* feat(cert): show Pending/Failed status badges in cert list

* feat(cert): add RetryCert component and wire into list actions

* feat(cert): inline Retry button on issuance error in wildcard modal

* chore(cert): minor cleanups after retry-on-failure review

- Remove unused model.FirstOrInit helper (last caller was rewritten in the issuance handler change).
- Normalize cleanup_test setupTestDB DSN to ":memory:" for per-test isolation, matching issue_test.go.
- Reset errored state in DNSIssueCertificate.open() as a defensive guard against stale state on modal reopen.

* refactor(cert): extract IssueCertModal wrapper shared by Renew and Retry

Both RenewCert.vue and RetryCert.vue carried near-identical AModal +
ObtainCertLive scaffolding (modalVisible/modalClosable refs, template ref,
modal props). Lift the shared shell into IssueCertModal.vue and expose a
single start() method returning Promise<CertificateResult>. The trigger
components now own only the parts that actually differ: button styling,
emit name, pre-issuance hook (certStore.save for Renew), and success toast.

* chore(cert): fix small bugs with review

- shortError now truncates by rune count instead of bytes, so non-ASCII
  error messages (e.g. localized ACME / DNS provider errors) cannot be
  split mid-rune. TestShortError gains a CJK case asserting valid UTF-8.
- Cert.last_attempt_at is typed string | null on the frontend to reflect
  that the *time.Time pointer serializes as null for legacy / pre-attempt
  rows.
- Drop redundant ?. on refModal / refObtainCertLive in the three click
  handlers. The refs are bound to components rendered alongside their
  trigger button, so they are guaranteed to be mounted by the time the
  handler fires.

* fix(cert): guard certificate issuance ref before retry

Co-authored-by: Jacky <me@jackyu.cn>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Jacky <me@jackyu.cn>
This commit is contained in:
Hintay
2026-05-23 21:34:52 +09:00
committed by GitHub
parent 79e69e3177
commit 013634e8ca
17 changed files with 695 additions and 113 deletions
+159 -46
View File
@@ -1,17 +1,18 @@
package certificate
import (
"strings"
"time"
"github.com/0xJacky/Nginx-UI/internal/cert"
"github.com/0xJacky/Nginx-UI/internal/helper"
"github.com/0xJacky/Nginx-UI/internal/middleware"
"github.com/0xJacky/Nginx-UI/internal/translation"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/gin-gonic/gin"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/gorilla/websocket"
"github.com/uozi-tech/cosy/logger"
"gorm.io/gen/field"
)
const (
@@ -34,35 +35,48 @@ func IssueCert(c *gin.Context) {
CheckOrigin: middleware.CheckWebSocketOrigin,
}
// upgrade http to websocket
ws, err := upGrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
logger.Error(err)
return
}
defer ws.Close()
wsWriter := helper.NewSafeWebSocketWriter(ws)
// read
payload := &cert.ConfigPayload{}
err = ws.ReadJSON(payload)
if err != nil {
if err := ws.ReadJSON(payload); err != nil {
logger.Error(err)
return
}
payload.KeyType = payload.GetKeyType()
certModel, err := model.FirstOrInit(name, payload.GetKeyType())
certModel, err := persistCertDraft(name, payload)
if err != nil {
logger.Error(err)
_ = wsWriter.WriteJSON(IssueCertResponse{Status: Error, Message: err.Error()})
return
}
payload.CertID = certModel.ID
// Defer guard: if the function returns while still pending (panic / unexpected path),
// the record would otherwise be orphaned. Convert to failure with a generic message.
defer func() {
var current model.Cert
db := model.UseDB()
if db == nil {
return
}
if e := db.Where("id = ?", certModel.ID).First(&current).Error; e != nil {
return
}
if current.Status == model.CertStatusPending {
markCertFailure(certModel.ID, "Issuance interrupted before completion.")
}
}()
// Hydrate payload.Resource from the existing cert (for renewal path).
if certModel.SSLCertificatePath != "" {
certInfo, _ := cert.GetCertInfo(certModel.SSLCertificatePath)
if certInfo != nil {
@@ -72,58 +86,157 @@ func IssueCert(c *gin.Context) {
}
log := cert.NewLogger()
log.SetCertModel(&certModel)
log.SetCertModel(certModel)
log.SetWebSocket(wsWriter)
defer log.Close()
err = cert.IssueCert(payload, log)
if err != nil {
if err := cert.IssueCert(payload, log); err != nil {
log.Error(err)
_ = wsWriter.WriteJSON(IssueCertResponse{
Status: Error,
Message: err.Error(),
})
markCertFailure(certModel.ID, shortError(err))
_ = wsWriter.WriteJSON(IssueCertResponse{Status: Error, Message: err.Error()})
return
}
cert := query.Cert
markCertSuccess(certModel.ID, payload.GetCertificatePath(), payload.GetCertificateKeyPath(), payload.Resource)
_, err = cert.Where(cert.Name.Eq(name), cert.Filename.Eq(name),
cert.KeyType.In(helper.GetKeyTypeAliasStrings(payload.KeyType)...)).
Assign(field.Attrs(&model.Cert{
KeyType: payload.KeyType,
Domains: payload.ServerName,
SSLCertificatePath: payload.GetCertificatePath(),
SSLCertificateKeyPath: payload.GetCertificateKeyPath(),
AutoCert: model.AutoCertEnabled,
ChallengeMethod: payload.ChallengeMethod,
DnsCredentialID: payload.DNSCredentialID,
ACMEUserID: payload.ACMEUserID,
Resource: payload.Resource,
MustStaple: payload.MustStaple,
LegoDisableCNAMESupport: payload.LegoDisableCNAMESupport,
Log: log.ToString(),
RevokeOld: payload.RevokeOld,
})).FirstOrCreate()
if err != nil {
logger.Error(err)
_ = wsWriter.WriteJSON(IssueCertResponse{
Status: Error,
Message: err.Error(),
})
return
}
err = wsWriter.WriteJSON(IssueCertResponse{
if err := wsWriter.WriteJSON(IssueCertResponse{
Status: Success,
Message: translation.C("[Nginx UI] Issued certificate successfully").ToString(),
SSLCertificate: payload.GetCertificatePath(),
SSLCertificateKey: payload.GetCertificateKeyPath(),
KeyType: payload.GetKeyType(),
})
if err != nil {
}); err != nil {
if helper.IsUnexpectedWebsocketError(err) {
logger.Error(err)
}
return
}
}
// persistCertDraft inserts or updates a Cert row representing an in-flight issuance.
// The row is keyed by (name, filename, key_type). All user-submitted config is captured
// up-front so a failure preserves enough state for a one-click retry.
func persistCertDraft(name string, payload *cert.ConfigPayload) (*model.Cert, error) {
db := model.UseDB()
normalizedKeyType := helper.GetKeyType(payload.GetKeyType())
keyTypeAliases := helper.GetKeyTypeAliasStrings(normalizedKeyType)
now := time.Now()
seed := &model.Cert{
Name: name,
Filename: name,
KeyType: normalizedKeyType,
Domains: payload.ServerName,
ChallengeMethod: payload.ChallengeMethod,
DnsCredentialID: payload.DNSCredentialID,
ACMEUserID: payload.ACMEUserID,
AutoCert: model.AutoCertEnabled,
MustStaple: payload.MustStaple,
LegoDisableCNAMESupport: payload.LegoDisableCNAMESupport,
RevokeOld: payload.RevokeOld,
Status: model.CertStatusPending,
LastError: "",
LastAttemptAt: &now,
}
// FirstOrCreate by (name, filename, key_type). When the row exists,
// `seed` is hydrated with the existing record (preserving SSLCertificatePath,
// Resource, etc.) so we can read those fields on the renewal path below.
if err := db.Where("name = ? AND filename = ? AND key_type IN ?", name, name, keyTypeAliases).
FirstOrCreate(seed).Error; err != nil {
return nil, err
}
// Refresh all user-submitted config and reset issuance state to pending.
// Use struct + Select so GORM applies the `serializer:json` tag for Domains
// AND writes the zero-valued LastError ("") instead of skipping it.
updates := &model.Cert{
Domains: payload.ServerName,
ChallengeMethod: payload.ChallengeMethod,
DnsCredentialID: payload.DNSCredentialID,
ACMEUserID: payload.ACMEUserID,
AutoCert: model.AutoCertEnabled,
MustStaple: payload.MustStaple,
LegoDisableCNAMESupport: payload.LegoDisableCNAMESupport,
RevokeOld: payload.RevokeOld,
Status: model.CertStatusPending,
LastError: "",
LastAttemptAt: &now,
}
if err := db.Model(&model.Cert{}).Where("id = ?", seed.ID).
Select(
"domains", "challenge_method", "dns_credential_id", "acme_user_id",
"auto_cert", "must_staple", "lego_disable_cname_support",
"revoke_old", "status", "last_error", "last_attempt_at",
).
Updates(updates).Error; err != nil {
return nil, err
}
// Re-read so the caller has the fully-populated struct (Resource, paths, etc.).
var fresh model.Cert
if err := db.Where("id = ?", seed.ID).First(&fresh).Error; err != nil {
return nil, err
}
return &fresh, nil
}
// markCertFailure updates only the failure-related columns. It explicitly
// avoids touching SSLCertificatePath / SSLCertificateKeyPath / Resource so
// a renew failure does not destroy the previously-issued certificate.
// Map-based Updates is safe here because neither column has a serializer tag.
func markCertFailure(id uint64, lastError string) {
db := model.UseDB()
if db == nil {
return
}
if err := db.Model(&model.Cert{}).Where("id = ?", id).Updates(map[string]any{
"status": model.CertStatusFailure,
"last_error": lastError,
}).Error; err != nil {
logger.Errorf("markCertFailure: %v", err)
}
}
// markCertSuccess updates the cert with the freshly-issued paths and Resource,
// flips status to success, and clears any prior last_error. Uses struct + Select
// so GORM applies the `serializer:json[aes]` tag for Resource AND writes the
// zero-valued LastError ("").
func markCertSuccess(id uint64, sslCertificatePath, sslCertificateKeyPath string, resource *model.CertificateResource) {
db := model.UseDB()
if db == nil {
return
}
updates := &model.Cert{
SSLCertificatePath: sslCertificatePath,
SSLCertificateKeyPath: sslCertificateKeyPath,
Resource: resource,
Status: model.CertStatusSuccess,
LastError: "",
}
cols := []string{"ssl_certificate_path", "ssl_certificate_key_path", "status", "last_error"}
if resource != nil {
cols = append(cols, "resource")
}
if err := db.Model(&model.Cert{}).Where("id = ?", id).
Select(cols).Updates(updates).Error; err != nil {
logger.Errorf("markCertSuccess: %v", err)
}
}
// shortError trims and truncates an error for UI display in last_error.
// Returns "" for nil so a successful retry can clear the prior error.
// Truncation is rune-aware so non-ASCII error messages (e.g. localized
// ACME or DNS provider errors) cannot be split mid-rune.
func shortError(err error) string {
if err == nil {
return ""
}
msg := strings.TrimSpace(err.Error())
const maxRunes = 500
runes := []rune(msg)
if len(runes) > maxRunes {
msg = string(runes[:maxRunes]) + "…"
}
return msg
}
+170
View File
@@ -0,0 +1,170 @@
package certificate
import (
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/0xJacky/Nginx-UI/internal/cert"
"github.com/0xJacky/Nginx-UI/model"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupCertTestDB(t *testing.T) *gorm.DB {
t.Helper()
// Use a per-test private in-memory DB. The literal ":memory:" (no shared cache)
// gives each gorm.Open a fresh isolated database, preventing cross-test pollution.
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Cert{}))
model.Use(db)
t.Cleanup(func() { model.Use(nil) })
return db
}
func TestPersistCertDraftCreatesPendingRecord(t *testing.T) {
db := setupCertTestDB(t)
payload := &cert.ConfigPayload{
ServerName: []string{"example.com", "*.example.com"},
ChallengeMethod: "dns01",
DNSCredentialID: 42,
ACMEUserID: 7,
KeyType: certcrypto.RSA2048,
MustStaple: true,
LegoDisableCNAMESupport: true,
RevokeOld: true,
}
got, err := persistCertDraft("example.com", payload)
require.NoError(t, err)
assert.NotZero(t, got.ID)
assert.Equal(t, model.CertStatusPending, got.Status)
assert.Equal(t, "", got.LastError)
assert.NotNil(t, got.LastAttemptAt)
assert.WithinDuration(t, time.Now(), *got.LastAttemptAt, 5*time.Second)
var fromDB model.Cert
require.NoError(t, db.First(&fromDB, got.ID).Error)
assert.Equal(t, []string{"example.com", "*.example.com"}, fromDB.Domains)
assert.Equal(t, "dns01", fromDB.ChallengeMethod)
assert.Equal(t, uint64(42), fromDB.DnsCredentialID)
assert.Equal(t, uint64(7), fromDB.ACMEUserID)
assert.True(t, fromDB.MustStaple)
assert.True(t, fromDB.LegoDisableCNAMESupport)
assert.True(t, fromDB.RevokeOld)
assert.Equal(t, model.AutoCertEnabled, fromDB.AutoCert)
}
func TestPersistCertDraftReusesExistingRow(t *testing.T) {
db := setupCertTestDB(t)
existing := model.Cert{
Name: "example.com",
Filename: "example.com",
KeyType: certcrypto.RSA2048,
Status: model.CertStatusFailure,
LastError: "prior failure",
}
require.NoError(t, db.Create(&existing).Error)
payload := &cert.ConfigPayload{
ServerName: []string{"example.com"},
ChallengeMethod: "http01",
KeyType: certcrypto.RSA2048,
}
got, err := persistCertDraft("example.com", payload)
require.NoError(t, err)
assert.Equal(t, existing.ID, got.ID)
assert.Equal(t, model.CertStatusPending, got.Status)
assert.Equal(t, "", got.LastError)
var count int64
require.NoError(t, db.Model(&model.Cert{}).Where("name = ?", "example.com").Count(&count).Error)
assert.Equal(t, int64(1), count, "should reuse, not duplicate")
}
func TestMarkCertFailureSetsStatusAndError(t *testing.T) {
db := setupCertTestDB(t)
c := model.Cert{Name: "example.com", Filename: "example.com", Status: model.CertStatusPending}
require.NoError(t, db.Create(&c).Error)
markCertFailure(c.ID, "DNS challenge timed out after 60s")
var got model.Cert
require.NoError(t, db.First(&got, c.ID).Error)
assert.Equal(t, model.CertStatusFailure, got.Status)
assert.Equal(t, "DNS challenge timed out after 60s", got.LastError)
}
func TestMarkCertFailureDoesNotClobberResourceOrPaths(t *testing.T) {
db := setupCertTestDB(t)
c := model.Cert{
Name: "example.com",
Filename: "example.com",
Status: model.CertStatusPending,
SSLCertificatePath: "/etc/nginx/ssl/example.com/fullchain.cer",
SSLCertificateKeyPath: "/etc/nginx/ssl/example.com/private.key",
}
require.NoError(t, db.Create(&c).Error)
markCertFailure(c.ID, "renewal failed")
var got model.Cert
require.NoError(t, db.First(&got, c.ID).Error)
assert.Equal(t, "/etc/nginx/ssl/example.com/fullchain.cer", got.SSLCertificatePath, "must not erase paths")
assert.Equal(t, "/etc/nginx/ssl/example.com/private.key", got.SSLCertificateKeyPath)
}
func TestMarkCertSuccessClearsLastError(t *testing.T) {
db := setupCertTestDB(t)
c := model.Cert{
Name: "example.com",
Filename: "example.com",
Status: model.CertStatusPending,
LastError: "stale error",
}
require.NoError(t, db.Create(&c).Error)
markCertSuccess(c.ID, "/etc/nginx/ssl/example.com/fullchain.cer", "/etc/nginx/ssl/example.com/private.key", nil)
var got model.Cert
require.NoError(t, db.First(&got, c.ID).Error)
assert.Equal(t, model.CertStatusSuccess, got.Status)
assert.Equal(t, "", got.LastError)
assert.Equal(t, "/etc/nginx/ssl/example.com/fullchain.cer", got.SSLCertificatePath)
assert.Equal(t, "/etc/nginx/ssl/example.com/private.key", got.SSLCertificateKeyPath)
}
func TestShortError(t *testing.T) {
assert.Equal(t, "", shortError(nil))
assert.Equal(t, "hello", shortError(errString(" hello ")))
long := make([]byte, 600)
for i := range long {
long[i] = 'a'
}
got := shortError(errString(string(long)))
// 500 ASCII runes + the literal "…" suffix.
assert.Equal(t, 500+len("…"), len(got))
assert.Equal(t, "…", got[len(got)-len("…"):])
// Multi-byte runes must not be split: each CJK char is 3 bytes,
// 600 chars = 1800 bytes, which would corrupt the boundary if we
// sliced by bytes. After rune-aware truncation we expect exactly
// 500 CJK chars + the "…" suffix, and the result must be valid UTF-8.
multi := strings.Repeat("中", 600)
gotMulti := shortError(errString(multi))
assert.True(t, utf8.ValidString(gotMulti), "truncated message must be valid UTF-8")
assert.Equal(t, 500+1 /* ellipsis rune */, utf8.RuneCountInString(gotMulti))
assert.Equal(t, "…", gotMulti[len(gotMulti)-len("…"):])
}
type errString string
func (e errString) Error() string { return string(e) }
+11
View File
@@ -5,6 +5,14 @@ import type { DnsCredential } from '@/api/dns_credential'
import type { PrivateKeyType } from '@/constants'
import { useCurdApi } from '@uozi-admin/request'
export const CertStatus = {
Pending: 'pending',
Success: 'success',
Failure: 'failure',
} as const
export type CertStatusType = '' | typeof CertStatus[keyof typeof CertStatus]
export interface Cert extends ModelBase {
name: string
domains: string[]
@@ -24,6 +32,9 @@ export interface Cert extends ModelBase {
certificate_info: CertificateInfo
sync_node_ids: number[]
revoke_old: boolean
status: CertStatusType
last_error: string
last_attempt_at: string | null
}
export interface CertificateInfo {
@@ -5,6 +5,7 @@ import cert from '@/api/cert'
import { useGlobalStore } from '@/pinia'
import WildcardCertificate from '../components/DNSIssueCertificate.vue'
import RemoveCert from '../components/RemoveCert.vue'
import RetryCert from '../components/RetryCert.vue'
import certColumns from './certColumns'
const refWildcard = ref()
@@ -48,6 +49,11 @@ const { processingStatus } = storeToRefs(globalStore)
@edit-item="record => $router.push(`/certificates/${record.id}`)"
>
<template #afterActions="{ record }">
<RetryCert
v-if="record.status === 'failure'"
:cert="record"
@retried="() => refTable.refresh()"
/>
<RemoveCert
:id="record.id"
:certificate="record"
@@ -1,7 +1,7 @@
import type { CustomRenderArgs, StdTableColumn } from '@uozi-admin/curd'
import type { JSXElements } from '@/types'
import { datetimeRender, maskRender } from '@uozi-admin/curd'
import { Badge, Tag } from 'ant-design-vue'
import { Badge, Tag, Tooltip } from 'ant-design-vue'
import dayjs from 'dayjs'
import { PrivateKeyTypeMask } from '@/constants'
@@ -61,26 +61,39 @@ const columns: StdTableColumn[] = [{
pure: true,
}, {
title: () => $gettext('Status'),
dataIndex: 'certificate_info',
dataIndex: 'status',
pure: true,
customRender: (args: CustomRenderArgs) => {
const template: JSXElements = []
const text = args.text?.not_before
&& args.text?.not_after
&& !dayjs().isBefore(args.text?.not_before)
&& !dayjs().isAfter(args.text?.not_after)
if (text) {
template.push(<Badge status="success" />)
template.push(h('span', $gettext('Valid')))
const { record } = args
if (record.status === 'pending') {
return h('div', [
h(Badge, { status: 'processing' }),
h('span', $gettext('Issuing...')),
])
}
else {
template.push(<Badge status="error" />)
template.push(h('span', $gettext('Expired')))
if (record.status === 'failure') {
const errorMsg = record.last_error || $gettext('Issuance failed')
return h(Tooltip, { title: errorMsg }, () =>
h('div', [
h(Badge, { status: 'error' }),
h('span', $gettext('Failed')),
]))
}
return h('div', template)
const info = record.certificate_info
const valid = info?.not_before
&& info?.not_after
&& !dayjs().isBefore(info.not_before)
&& !dayjs().isAfter(info.not_after)
if (valid) {
return h('div', [
h(Badge, { status: 'success' }),
h('span', $gettext('Valid')),
])
}
return h('div', [
h(Badge, { status: 'error' }),
h('span', $gettext('Expired')),
])
},
}, {
title: () => $gettext('Not After'),
@@ -16,6 +16,7 @@ const data = ref({}) as Ref<AutoCertOptions>
const domain = ref('')
const certType = ref<'wildcard' | 'custom'>('wildcard')
const customDomains = ref<string[]>([''])
const errored = ref(false)
function open() {
visible.value = true
@@ -27,6 +28,7 @@ function open() {
domain.value = ''
certType.value = 'wildcard'
customDomains.value = ['']
errored.value = false
}
defineExpose({
@@ -84,14 +86,21 @@ function issueCert() {
}
}
step.value++
errored.value = false
step.value = 1
modalVisible.value = true
refObtainCertLive.value?.issue_cert(computedMainDomain.value, computedDomains.value, data.value.key_type)
// ObtainCertLive is mounted in the same modal via force-render, so the
// ref is guaranteed to be available by the time this function runs.
refObtainCertLive.value!
.issue_cert(computedMainDomain.value, computedDomains.value, data.value.key_type)
.then(() => {
message.success($gettext('Renew successfully'))
message.success($gettext('Issued successfully'))
emit('issued')
})
.catch(() => {
errored.value = true
})
}
</script>
@@ -198,6 +207,18 @@ function issueCert() {
v-model:modal-visible="modalVisible"
:options="data"
/>
<div
v-if="step === 1 && errored"
class="flex justify-end mt-4"
>
<AButton
type="primary"
@click="issueCert"
>
{{ $gettext('Retry') }}
</AButton>
</div>
</AModal>
</div>
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { AutoCertOptions } from '@/api/auto_cert'
import type { CertificateResult } from '@/api/cert'
import ObtainCertLive from '@/views/site/site_edit/components/Cert/ObtainCertLive.vue'
const props = defineProps<{
title: string
options: AutoCertOptions
}>()
const modalVisible = ref(false)
const modalClosable = ref(true)
const refObtainCertLive = useTemplateRef('refObtainCertLive')
function start(): Promise<CertificateResult> {
modalVisible.value = true
return new Promise<CertificateResult>((resolve, reject) => {
nextTick(() => {
const live = refObtainCertLive.value
if (!live) {
reject(new Error('ObtainCertLive not mounted'))
return
}
const { name, domains, key_type } = props.options
live.issue_cert(name!, domains, key_type).then(resolve).catch(reject)
})
})
}
defineExpose({ start })
</script>
<template>
<AModal
v-model:open="modalVisible"
:title="title"
:mask-closable="modalClosable"
:closable="modalClosable"
:footer="null"
:width="600"
force-render
>
<ObtainCertLive
ref="refObtainCertLive"
v-model:modal-closable="modalClosable"
v-model:modal-visible="modalVisible"
:options
/>
</AModal>
</template>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { AutoCertOptions } from '@/api/auto_cert'
import { useGlobalStore } from '@/pinia'
import ObtainCertLive from '@/views/site/site_edit/components/Cert/ObtainCertLive.vue'
import { useCertStore } from '../store'
import IssueCertModal from './IssueCertModal.vue'
const props = defineProps<{
defineProps<{
options: AutoCertOptions
}>()
@@ -13,23 +13,16 @@ const emit = defineEmits<{
}>()
const { message } = App.useApp()
const certStore = useCertStore()
const modalVisible = ref(false)
const modalClosable = ref(true)
const refObtainCertLive = useTemplateRef('refObtainCertLive')
const refModal = useTemplateRef('refModal')
async function issueCert() {
await certStore.save()
message.success($gettext('Save successfully'))
modalVisible.value = true
const { name, domains, key_type } = props.options
refObtainCertLive.value?.issue_cert(name!, domains, key_type).then(() => {
// refModal is mounted alongside this button via force-render, so it
// is guaranteed to be available by the time @click fires.
refModal.value!.start().then(() => {
message.success($gettext('Renew successfully'))
emit('renewed')
})
@@ -53,28 +46,10 @@ const { processingStatus } = storeToRefs(globalStore)
<span v-if="processingStatus.auto_cert_processing" class="ml-4">
{{ $gettext('AutoCert is running, please wait...') }}
</span>
<AModal
v-model:open="modalVisible"
<IssueCertModal
ref="refModal"
:title="$gettext('Renew Certificate')"
:mask-closable="modalClosable"
:footer="null"
:closable="modalClosable"
:width="600"
force-render
>
<ObtainCertLive
ref="refObtainCertLive"
v-model:modal-closable="modalClosable"
v-model:modal-visible="modalVisible"
:options
/>
</AModal>
:options
/>
</div>
</template>
<style lang="less" scoped>
.control-btn {
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { AutoCertOptions } from '@/api/auto_cert'
import type { Cert } from '@/api/cert'
import IssueCertModal from './IssueCertModal.vue'
const props = defineProps<{
cert: Cert
}>()
const emit = defineEmits<{
retried: []
}>()
const { message } = App.useApp()
const refModal = useTemplateRef('refModal')
const issueOptions = computed<AutoCertOptions>(() => ({
name: props.cert.name,
domains: props.cert.domains,
key_type: props.cert.key_type,
challenge_method: props.cert.challenge_method,
dns_credential_id: props.cert.dns_credential_id,
acme_user_id: props.cert.acme_user_id,
revoke_old: props.cert.revoke_old,
}))
function openAndRetry() {
// refModal is bound to a component rendered alongside this button,
// so it is guaranteed to be mounted by the time @click fires.
refModal.value!
.start()
.then(() => {
message.success($gettext('Certificate issued successfully'))
emit('retried')
})
.catch(() => {
// Error already surfaced inside ObtainCertLive's log.
})
}
</script>
<template>
<AButton
type="link"
size="small"
@click="openAndRetry"
>
{{ $gettext('Retry') }}
</AButton>
<IssueCertModal
ref="refModal"
:title="$gettext('Retry Certificate Issuance')"
:options="issueOptions"
/>
</template>
@@ -101,11 +101,21 @@ function ensureTLSDirectives(sslCertificate: string, sslCertificateKey: string)
}
function issueCert() {
refObtainCertLive.value?.issue_cert(
const live = refObtainCertLive.value
if (!live) {
modalClosable.value = true
issuingCert.value = false
message.error($gettext('Certificate issuance component is not ready'))
return
}
live.issue_cert(
props.configName,
name.value.trim().split(' '),
data.value.key_type,
).then(resolveCert)
).then(resolveCert).catch(() => {
// The live log already shows the issuance failure details.
})
}
async function resolveCert({ ssl_certificate, ssl_certificate_key, key_type }: CertificateResult) {
+16
View File
@@ -42,6 +42,11 @@ func autoCert(certModel *model.Cert) {
targetName := getAutoRenewTargetName(certModel)
now := time.Now()
if shouldSkipAutoCertByStatus(certModel) {
logger.Infof("Skip auto cert for %s due to status %s", targetName, certModel.Status)
return
}
if shouldSkipAutoRenew(certModel, now) {
logger.Infof("Skip auto renew for %s until %s after previous failure", targetName,
certModel.LastAutoRenewAt.Add(autoRenewFailureRetryCooldown).Format(time.DateTime))
@@ -145,6 +150,17 @@ func shouldSkipAutoRenew(certModel *model.Cert, now time.Time) bool {
return now.Before(certModel.LastAutoRenewAt.Add(autoRenewFailureRetryCooldown))
}
// shouldSkipAutoCertByStatus returns true when the cert's most recent
// issuance attempt has not succeeded. Pending and failed certs must
// be retried by the user explicitly; auto-renew should not touch them.
func shouldSkipAutoCertByStatus(certModel *model.Cert) bool {
if certModel == nil {
return false
}
return certModel.Status == model.CertStatusPending ||
certModel.Status == model.CertStatusFailure
}
func handleAutoRenewFailure(certModel *model.Cert, log *Logger, name string, err error) {
log.Error(err)
updateAutoRenewStatus(certModel, time.Now(), err.Error())
+21
View File
@@ -98,3 +98,24 @@ func TestGetAutoRenewNotificationResponseFallsBackToPlainText(t *testing.T) {
t.Fatalf("unexpected fallback response: %s", text)
}
}
func TestShouldSkipAutoCertForNonSuccessStatus(t *testing.T) {
cases := []struct {
name string
status string
expected bool
}{
{"pending is skipped", model.CertStatusPending, true},
{"failure is skipped", model.CertStatusFailure, true},
{"success is renewed", model.CertStatusSuccess, false},
{"empty (legacy) is renewed", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cert := &model.Cert{Status: tc.status}
if got := shouldSkipAutoCertByStatus(cert); got != tc.expected {
t.Fatalf("shouldSkipAutoCertByStatus(%q) = %v, want %v", tc.status, got, tc.expected)
}
})
}
}
+32
View File
@@ -0,0 +1,32 @@
package cert
import (
"github.com/0xJacky/Nginx-UI/model"
"github.com/uozi-tech/cosy/logger"
)
// SweepStalePending converts any pending cert records to failure.
// Called at server startup: a process restart kills in-flight issuance,
// leaving the WebSocket client with no way to receive a terminal status.
// Any record still marked pending at boot is by definition orphaned.
func SweepStalePending() error {
db := model.UseDB()
if db == nil {
return nil
}
result := db.Model(&model.Cert{}).
Where("status = ?", model.CertStatusPending).
Updates(map[string]any{
"status": model.CertStatusFailure,
"last_error": "Server restarted during issuance",
})
if result.Error != nil {
logger.Errorf("SweepStalePending: %v", result.Error)
return result.Error
}
if result.RowsAffected > 0 {
logger.Infof("SweepStalePending: converted %d pending cert(s) to failure", result.RowsAffected)
}
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package cert
import (
"testing"
"github.com/0xJacky/Nginx-UI/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// setupTestDB creates a per-test private in-memory SQLite DB with the Cert
// schema migrated, wires it into the model package, and returns the *gorm.DB
// for fixtures. Using ":memory:" (no shared cache) gives each gorm.Open call
// a fresh isolated database, preventing cross-test pollution.
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Cert{}))
model.Use(db)
t.Cleanup(func() { model.Use(nil) })
return db
}
func TestSweepStalePendingConvertsPendingToFailure(t *testing.T) {
db := setupTestDB(t)
pending := model.Cert{
Name: "example.com",
Filename: "example.com",
Status: model.CertStatusPending,
}
require.NoError(t, db.Create(&pending).Error)
require.NoError(t, SweepStalePending())
var got model.Cert
require.NoError(t, db.First(&got, pending.ID).Error)
assert.Equal(t, model.CertStatusFailure, got.Status)
assert.Equal(t, "Server restarted during issuance", got.LastError)
}
func TestSweepStalePendingLeavesSuccessAndFailureAlone(t *testing.T) {
db := setupTestDB(t)
success := model.Cert{Name: "ok.example.com", Filename: "ok.example.com", Status: model.CertStatusSuccess}
failure := model.Cert{Name: "bad.example.com", Filename: "bad.example.com", Status: model.CertStatusFailure, LastError: "DNS timeout"}
empty := model.Cert{Name: "legacy.example.com", Filename: "legacy.example.com"}
require.NoError(t, db.Create(&success).Error)
require.NoError(t, db.Create(&failure).Error)
require.NoError(t, db.Create(&empty).Error)
require.NoError(t, SweepStalePending())
var gotSuccess, gotFailure, gotEmpty model.Cert
require.NoError(t, db.First(&gotSuccess, success.ID).Error)
require.NoError(t, db.First(&gotFailure, failure.ID).Error)
require.NoError(t, db.First(&gotEmpty, empty.ID).Error)
assert.Equal(t, model.CertStatusSuccess, gotSuccess.Status)
assert.Equal(t, model.CertStatusFailure, gotFailure.Status)
assert.Equal(t, "DNS timeout", gotFailure.LastError)
assert.Equal(t, "", gotEmpty.Status)
}
func TestSweepStalePendingNoDBIsNoop(t *testing.T) {
model.Use(nil)
assert.NoError(t, SweepStalePending())
}
+7
View File
@@ -3,6 +3,7 @@ package cron
import (
"context"
"github.com/0xJacky/Nginx-UI/internal/cert"
"github.com/go-co-op/gocron/v2"
"github.com/uozi-tech/cosy/logger"
)
@@ -20,6 +21,12 @@ func init() {
// InitCronJobs initializes and starts all cron jobs
func InitCronJobs(ctx context.Context) {
// Sweep any cert rows still marked "pending" — they belong to an
// issuance that was killed by the previous shutdown.
if err := cert.SweepStalePending(); err != nil {
logger.Errorf("SweepStalePending Err: %v\n", err)
}
// Initialize auto cert job
_, err := setupAutoCertJob(s)
if err != nil {
+9 -9
View File
@@ -17,6 +17,12 @@ const (
AutoCertDisabled = -1
CertChallengeMethodHTTP01 = "http01"
CertChallengeMethodDNS01 = "dns01"
// CertStatus values track the most recent issuance attempt outcome.
// Empty string represents pre-migration / imported certificates.
CertStatusPending = "pending"
CertStatusSuccess = "success"
CertStatusFailure = "failure"
)
type CertDomains []string
@@ -52,6 +58,9 @@ type Cert struct {
RevokeOld bool `json:"revoke_old"`
LastAutoRenewAt *time.Time `json:"-"`
LastAutoRenewError string `json:"-"`
Status string `json:"status"`
LastError string `json:"last_error"`
LastAttemptAt *time.Time `json:"last_attempt_at"`
}
func FirstCert(confName string) (c Cert, err error) {
@@ -73,15 +82,6 @@ func FirstOrCreateCert(confName string, keyType certcrypto.KeyType) (c Cert, err
return
}
func FirstOrInit(confName string, keyType certcrypto.KeyType) (c Cert, err error) {
normalizedKeyType := helper.GetKeyType(keyType)
err = db.Where("name = ? AND filename = ? AND key_type IN ?", confName, confName,
helper.GetKeyTypeAliasStrings(normalizedKeyType)).
FirstOrInit(&c, &Cert{Name: confName, Filename: confName, KeyType: normalizedKeyType}).Error
c.KeyType = normalizedKeyType
return
}
func (c *Cert) Insert() error {
return db.Create(c).Error
}
+13 -1
View File
@@ -50,6 +50,9 @@ func newCert(db *gorm.DB, opts ...gen.DOOption) cert {
_cert.RevokeOld = field.NewBool(tableName, "revoke_old")
_cert.LastAutoRenewAt = field.NewTime(tableName, "last_auto_renew_at")
_cert.LastAutoRenewError = field.NewString(tableName, "last_auto_renew_error")
_cert.Status = field.NewString(tableName, "status")
_cert.LastError = field.NewString(tableName, "last_error")
_cert.LastAttemptAt = field.NewTime(tableName, "last_attempt_at")
_cert.DnsCredential = certBelongsToDnsCredential{
db: db.Session(&gorm.Session{}),
@@ -93,6 +96,9 @@ type cert struct {
RevokeOld field.Bool
LastAutoRenewAt field.Time
LastAutoRenewError field.String
Status field.String
LastError field.String
LastAttemptAt field.Time
DnsCredential certBelongsToDnsCredential
ACMEUser certBelongsToACMEUser
@@ -134,6 +140,9 @@ func (c *cert) updateTableName(table string) *cert {
c.RevokeOld = field.NewBool(table, "revoke_old")
c.LastAutoRenewAt = field.NewTime(table, "last_auto_renew_at")
c.LastAutoRenewError = field.NewString(table, "last_auto_renew_error")
c.Status = field.NewString(table, "status")
c.LastError = field.NewString(table, "last_error")
c.LastAttemptAt = field.NewTime(table, "last_attempt_at")
c.fillFieldMap()
@@ -150,7 +159,7 @@ func (c *cert) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
func (c *cert) fillFieldMap() {
c.fieldMap = make(map[string]field.Expr, 24)
c.fieldMap = make(map[string]field.Expr, 27)
c.fieldMap["id"] = c.ID
c.fieldMap["created_at"] = c.CreatedAt
c.fieldMap["updated_at"] = c.UpdatedAt
@@ -173,6 +182,9 @@ func (c *cert) fillFieldMap() {
c.fieldMap["revoke_old"] = c.RevokeOld
c.fieldMap["last_auto_renew_at"] = c.LastAutoRenewAt
c.fieldMap["last_auto_renew_error"] = c.LastAutoRenewError
c.fieldMap["status"] = c.Status
c.fieldMap["last_error"] = c.LastError
c.fieldMap["last_attempt_at"] = c.LastAttemptAt
}