mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
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:
+159
-46
@@ -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(¤t).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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user