feat: self-signed certificate support (#1655) (#1688)

* feat(cert): add self-signed certificate type and config to model

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cert): generate self-signed leaf certificates

Add GenerateSelfSigned / SelfSignedOptions plus five new error codes
(50032-50036) and a full TDD test suite covering valid cert output,
multiple key types, empty-SAN rejection, and invalid-IP rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cert): regenerate self-signed certificates with key reuse

Add RegenerateSelfSigned, SelfSignedOptionsFromModel, deriveSelfSignedCommonName,
loadSelfSignedKey, and parsePrivateKeyPEM to support re-issuing self-signed
certificates for the auto-renewal job, reusing the on-disk private key when possible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cron): schedule self-signed certificate renewal

Register setupSelfSignedCertRenewalJob as a periodic cron job (every
30 minutes) in InitCronJobs, mirroring the existing setupAutoCertJob
pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): add self-signed certificate generation endpoints

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cert): add self-signed certificate frontend API

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cert): add shared self-signed certificate fields component

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cert): add self-signed certificate generation modal and list entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cert): support self-signed certificates in the editor

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(site): generate self-signed certificates from the site editor

Extract hasTLSListen/ensureDirective/ensureTLSDirectives into a shared
useTLSDirectives composable, refactor ObtainCert.vue to use it, and add
SelfSignedCert.vue to the site cert tab so users can generate and apply
a self-signed certificate directly from the site editor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cert): validate self-signed key type and name IP-only renewals

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cert): apply code-review cleanup

- reuse certcrypto.ParsePEMPrivateKey instead of a hand-rolled PEM
  private-key parser
- stop exporting the unused ensureDirective from useTLSDirectives
- use the AutoCertState enum instead of integer literals in certColumns
- allocate the renewal Logger only when renewal is attempted, avoiding a
  per-tick goroutine and empty-log database write for non-due certificates

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cert): address PR #1688 review feedback

- clean up the partial certificate directory when the initial write
  fails, not just the database row
- log a warning when the existing self-signed private key cannot be
  reused so operators notice the public-key fingerprint has changed
- defensively copy the model's Domains and IPAddresses slices in
  SelfSignedOptionsFromModel
- require an explicit "Save now" confirmation after generating from the
  site editor, and write the directives into the editor first so the
  user can review the diff before saving

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cert): harden self-signed certificate lifecycle

Reuse private keys on manual self-signed edits, make certificate writes safer, clean managed self-signed files on delete, and guard renewal against missing config.

* fix(cert): harden self-signed frontend handling

Avoid undefined certificate redirects, rely on payload defaults for self-signed fields, and parse TLS listen directives precisely.

* fix(site): satisfy strict listen regex lint

Escape the IPv6 listen closing bracket explicitly so the strict regexp lint rule accepts TLS listen parsing.

* fix(cert): harden self-signed key handling

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

* docs(cert): design merging self-signed entry into issue dialog

Spec for collapsing the Certificate list header from three actions to
two by adding a Self-signed option inside the existing Issue Certificate
dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cert): plan merging self-signed into issue dialog

Step-by-step plan that turns the spec into two scoped commits:
extend DNSIssueCertificate with a self-signed type, then drop the
standalone header button from the certificate list view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cert): add self-signed option in issue certificate dialog

Extend the Issue Certificate dialog's Certificate Type select with a
"Self-signed" option that swaps the form body to SelfSignedCertFields
and routes submission through cert.generate_self_signed(). ACME paths
(Wildcard / Custom Domains) are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cert): drop standalone self-signed button from list header

Certificate creation is now consolidated under the Issue Certificate
dialog (which exposes Self-signed as a Certificate Type option), so
the duplicate header entry, its ref, handler, and modal mount are
removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cert): design self-signed UX enhancements

Adds a reusable StringListInput, renewal-policy hint in the self-signed
form, and a required Name field (frontend + backend). Builds on the
prior merge spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cert): plan self-signed UX enhancements

Six-task plan: extract StringListInput, require Name backend + test,
refactor SelfSignedCertFields with renewal hint, hide duplicate alert
in editor, seed/filter payloads with Name validation, and adopt
StringListInput in the ACME Custom Domains branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): add StringListInput component

Reusable multi-row text input with Add/Remove buttons. Used in the
upcoming refactor of Custom Domains and self-signed Domains / IP
Addresses editors so all three share a single editor pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui): simplify StringListInput model write and add a11y label

Replace the captured-index update closure with v-model:value on
items[index] so input events are guaranteed to write to the array
slot currently bound to the DOM input. Add an aria-label suffix
on the Remove button so screen readers can distinguish rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cert): require Name when generating self-signed certificates

Adds binding:"required" to SelfSignedCertRequest.Name so an empty name
is rejected at the request boundary, and covers the contract with a
new API-level test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cert): unify self-signed editor and surface renewal hint

Switch Domains and IP Addresses to the shared StringListInput so all
self-signed field editors match the Custom Domains pattern. Add an
auto-renewal hint (suppressible via hideRenewalNote) and mark Name as
required to match the new backend contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(cert): suppress duplicate renewal alert in cert editor

SelfSignedCertManagement already has its own renewal-status alert;
pass hide-renewal-note to SelfSignedCertFields to avoid showing two
adjacent alerts saying the same thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cert): seed and filter self-signed payloads, validate Name

StringListInput preserves empty placeholder rows for editing; seed
arrays with [''] in toSelfSignedPayload / emptySelfSignedPayload /
emptyForm so the editor always renders an empty row to type into.

Each submit/save path trims and filters the arrays before sending and
now rejects an empty Name client-side to match the new server contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cert): make SelfSignedCertPayload.name required

Every factory already seeds name as ''; the optional marker forced
defensive (name ?? '').trim() at three call sites. Align the type
with reality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(cert): use StringListInput for Custom Domains

Drop the inline multi-row template + add/remove helpers in favour of
the shared StringListInput component, matching the editor used by the
self-signed branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ui): regenerate components.d.ts for StringListInput

Auto-generated by unplugin-vue-components after the new component
was added under app/src/components/StringListInput/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cert): render key_type for both legacy and canonical forms

The backend's helper.GetKeyType normalizes key_type to its canonical
form (EC256, RSA2048…) on every write — self-signed generation as well
as the ModifyCert BeforeExecuteHook. The frontend PrivateKeyTypeMask
was keyed only by the legacy form (P256, 2048…), so maskRender returned
"/" for every cert that took a write path through normalization.

Two reported symptoms with the same root cause:
- New self-signed cert always shows "/" in the Key Type column
- Editing any ACME cert (issue #1697) flips its column to "/" after save

Add formatPrivateKeyType / normalizePrivateKeyType helpers that map both
forms to the frontend's legacy key. Use them in the list column renderer
and when loading certs into the self-signed and ACME editor forms so the
ASelect highlights the correct option.

Fixes #1697.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(cert): cap self-signed fields width at 600px

The fields stretched full-width inside the certificate editor page; cap
the form at 600px to match AutoCertManagement and keep the editing area
readable. Modal consumers were already bounded by their own width, so
the change is invisible there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update translations

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hintay <hintay@me.com>
This commit is contained in:
Jacky
2026-05-24 09:22:21 +08:00
committed by GitHub
parent 8a787e2485
commit 69cfa82b1d
50 changed files with 13820 additions and 9222 deletions
+35 -1
View File
@@ -3,6 +3,7 @@ package certificate
import (
"net/http"
"os"
"path/filepath"
"github.com/0xJacky/Nginx-UI/internal/cert"
"github.com/0xJacky/Nginx-UI/internal/helper"
@@ -198,7 +199,40 @@ func ModifyCert(c *gin.Context) {
}
func RemoveCert(c *gin.Context) {
cosy.Core[model.Cert](c).Destroy()
id := cast.ToUint64(c.Param("id"))
certModel, err := query.Cert.FirstByID(id)
if err != nil {
cosy.ErrHandler(c, err)
return
}
if err = query.Cert.DeleteByID(id); err != nil {
cosy.ErrHandler(c, err)
return
}
cleanupSelfSignedCertFiles(certModel)
}
func cleanupSelfSignedCertFiles(certModel *model.Cert) {
if certModel.AutoCert != model.AutoCertSelfSigned {
return
}
certPath := certModel.SSLCertificatePath
keyPath := certModel.SSLCertificateKeyPath
sslDir := nginx.GetConfPath("ssl")
certDir := filepath.Dir(certPath)
keyDir := filepath.Dir(keyPath)
if certDir == "." || certDir != keyDir {
return
}
if !helper.IsUnderDirectory(certPath, sslDir) || !helper.IsUnderDirectory(keyPath, sslDir) || !helper.IsUnderDirectory(certDir, sslDir) {
return
}
if err := os.RemoveAll(certDir); err != nil {
logger.Errorf("self-signed cert directory cleanup failed for id %d at %s: %v", certModel.ID, certDir, err)
}
}
func SyncCertificate(c *gin.Context) {
+2
View File
@@ -19,6 +19,8 @@ func InitCertificateRouter(r *gin.RouterGroup) {
r.PUT("cert_sync", SyncCertificate)
r.GET("certificate/dns_providers", GetDNSProvidersList)
r.GET("certificate/dns_provider/:code", GetDNSProvider)
r.POST("self_signed_cert", GenerateSelfSignedCert)
r.POST("self_signed_cert/:id", ModifySelfSignedCert)
}
func InitCertificateWebSocketRouter(r *gin.RouterGroup) {
+267
View File
@@ -0,0 +1,267 @@
package certificate
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/0xJacky/Nginx-UI/internal/cert"
"github.com/0xJacky/Nginx-UI/internal/helper"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/0xJacky/Nginx-UI/internal/notification"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/gin-gonic/gin"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/spf13/cast"
"github.com/uozi-tech/cosy"
"github.com/uozi-tech/cosy/logger"
"golang.org/x/net/idna"
)
// defaultSelfSignedSlug is the fallback certificate-directory slug.
const defaultSelfSignedSlug = "self_signed"
// SelfSignedCertRequest is the payload for generating or modifying a
// self-signed certificate.
type SelfSignedCertRequest struct {
Name string `json:"name" binding:"required"`
Domains []string `json:"domains" binding:"omitempty"`
IPAddresses []string `json:"ip_addresses" binding:"omitempty,dive,ip"`
KeyType string `json:"key_type" binding:"omitempty,auto_cert_key_type"`
ValidityDays int `json:"validity_days" binding:"omitempty,min=1,max=3650"`
SyncNodeIds []uint64 `json:"sync_node_ids" binding:"omitempty"`
}
// GenerateSelfSignedCert creates a new self-signed certificate.
func GenerateSelfSignedCert(c *gin.Context) {
var req SelfSignedCertRequest
if !cosy.BindAndValid(c, &req) {
return
}
opts, err := buildSelfSignedOptions(&req)
if err != nil {
cosy.ErrHandler(c, err)
return
}
db := model.UseDB()
certModel := &model.Cert{
Name: req.Name,
Domains: opts.DNSNames,
AutoCert: model.AutoCertSelfSigned,
KeyType: opts.KeyType,
SelfSignedConfig: &model.SelfSignedCertConfig{
IPAddresses: opts.IPAddresses,
ValidityDays: opts.ValidityDays,
},
SyncNodeIds: req.SyncNodeIds,
}
if err = db.Create(certModel).Error; err != nil {
cosy.ErrHandler(c, err)
return
}
// derive a unique, filesystem-safe certificate directory using the row ID
slug := selfSignedSlug(req.Name)
if slug == defaultSelfSignedSlug {
slug = selfSignedSlug(opts.CommonName)
}
dir := nginx.GetConfPath("ssl", slug+"_"+cast.ToString(certModel.ID))
certPath := filepath.Join(dir, "fullchain.cer")
keyPath := filepath.Join(dir, "private.key")
if err = writeSelfSignedFiles(certPath, keyPath, opts); err != nil {
// remove the partial directory so a failed generation leaves no orphan files
if rmErr := os.RemoveAll(dir); rmErr != nil {
logger.Errorf("self-signed cert directory cleanup failed for id %d at %s: %v",
certModel.ID, dir, rmErr)
}
// roll back the row so a failed generation leaves no orphan record
if rollbackErr := db.Delete(certModel).Error; rollbackErr != nil {
logger.Errorf("self-signed cert rollback failed for id %d: %v", certModel.ID, rollbackErr)
}
cosy.ErrHandler(c, err)
return
}
certModel.SSLCertificatePath = certPath
certModel.SSLCertificateKeyPath = keyPath
if err = db.Model(certModel).Updates(map[string]any{
"ssl_certificate_path": certPath,
"ssl_certificate_key_path": keyPath,
}).Error; err != nil {
logger.Errorf("self-signed cert id %d generated at %s but persisting paths failed: %v",
certModel.ID, dir, err)
if rmErr := os.RemoveAll(dir); rmErr != nil {
logger.Errorf("self-signed cert directory cleanup failed for id %d at %s: %v",
certModel.ID, dir, rmErr)
}
if rollbackErr := db.Delete(certModel).Error; rollbackErr != nil {
logger.Errorf("self-signed cert rollback failed for id %d: %v", certModel.ID, rollbackErr)
}
cosy.ErrHandler(c, err)
return
}
if err = cert.SyncToRemoteServer(certModel); err != nil {
notification.Error("Sync Certificate Error", err.Error(), nil)
}
c.JSON(http.StatusOK, Transformer(certModel))
}
// ModifySelfSignedCert re-issues an existing self-signed certificate.
func ModifySelfSignedCert(c *gin.Context) {
id := cast.ToUint64(c.Param("id"))
var req SelfSignedCertRequest
if !cosy.BindAndValid(c, &req) {
return
}
opts, err := buildSelfSignedOptions(&req)
if err != nil {
cosy.ErrHandler(c, err)
return
}
certModel, err := query.Cert.FirstByID(id)
if err != nil {
cosy.ErrHandler(c, err)
return
}
if certModel.AutoCert != model.AutoCertSelfSigned {
cosy.ErrHandler(c, cert.ErrCertIsNotSelfSigned)
return
}
if certModel.SSLCertificatePath == "" || certModel.SSLCertificateKeyPath == "" {
cosy.ErrHandler(c, cert.ErrCertPathIsEmpty)
return
}
// Reuse the existing file paths and private key so sites referencing them keep working.
// The stored key type must continue to describe the reused private key.
opts.KeyType = certModel.GetKeyType()
if err = rewriteSelfSignedFiles(certModel, certModel.SSLCertificatePath,
certModel.SSLCertificateKeyPath, opts); err != nil {
cosy.ErrHandler(c, err)
return
}
certModel.Name = req.Name
certModel.Domains = opts.DNSNames
certModel.KeyType = opts.KeyType
certModel.SelfSignedConfig = &model.SelfSignedCertConfig{
IPAddresses: opts.IPAddresses,
ValidityDays: opts.ValidityDays,
}
certModel.SyncNodeIds = req.SyncNodeIds
if err = model.UseDB().Save(certModel).Error; err != nil {
cosy.ErrHandler(c, err)
return
}
nginx.Reload()
if err = cert.SyncToRemoteServer(certModel); err != nil {
notification.Error("Sync Certificate Error", err.Error(), nil)
}
c.JSON(http.StatusOK, Transformer(certModel))
}
// buildSelfSignedOptions validates and normalizes the request into
// cert.SelfSignedOptions.
func buildSelfSignedOptions(req *SelfSignedCertRequest) (cert.SelfSignedOptions, error) {
domains := normalizeStringSlice(req.Domains)
ips := normalizeStringSlice(req.IPAddresses)
if len(domains) == 0 && len(ips) == 0 {
return cert.SelfSignedOptions{}, cert.ErrSelfSignedNoSAN
}
validityDays := req.ValidityDays
if validityDays <= 0 {
validityDays = cert.SelfSignedDefaultValidityDays
}
commonName := ""
if len(domains) > 0 {
commonName = domains[0]
} else {
commonName = ips[0]
}
return cert.SelfSignedOptions{
CommonName: commonName,
DNSNames: domains,
IPAddresses: ips,
KeyType: helper.GetKeyType(certcrypto.KeyType(req.KeyType)),
ValidityDays: validityDays,
}, nil
}
// writeSelfSignedFiles generates a self-signed certificate and writes it to
// the given paths.
func writeSelfSignedFiles(certPath, keyPath string, opts cert.SelfSignedOptions) error {
certPEM, keyPEM, err := cert.GenerateSelfSigned(opts)
if err != nil {
return err
}
return writeSelfSignedContent(certPath, keyPath, certPEM, keyPEM)
}
func rewriteSelfSignedFiles(certModel *model.Cert, certPath, keyPath string, opts cert.SelfSignedOptions) error {
certPEM, keyPEM, err := cert.RegenerateSelfSignedWithOptions(certModel, opts)
if err != nil {
return err
}
return writeSelfSignedContent(certPath, keyPath, certPEM, keyPEM)
}
func writeSelfSignedContent(certPath, keyPath string, certPEM, keyPEM []byte) error {
content := &cert.Content{
SSLCertificatePath: certPath,
SSLCertificateKeyPath: keyPath,
SSLCertificate: string(certPEM),
SSLCertificateKey: string(keyPEM),
}
return content.WriteFile()
}
// normalizeStringSlice trims entries and drops empty strings.
func normalizeStringSlice(in []string) []string {
out := make([]string, 0, len(in))
for _, s := range in {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
// selfSignedSlug builds a filesystem-safe directory slug from a name.
func selfSignedSlug(name string) string {
name = strings.TrimSpace(name)
if asciiName, err := idna.Lookup.ToASCII(name); err == nil {
name = asciiName
}
var b strings.Builder
for _, r := range strings.ToLower(name) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '.':
b.WriteRune(r)
case r == ' ' || r == '_':
b.WriteRune('_')
}
}
slug := strings.Trim(b.String(), "._-")
if slug == "" {
slug = defaultSelfSignedSlug
}
return slug
}
+200
View File
@@ -0,0 +1,200 @@
package certificate
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/0xJacky/Nginx-UI/internal/cert"
"github.com/0xJacky/Nginx-UI/internal/validation"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/query"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/gin-gonic/gin"
"github.com/go-acme/lego/v5/certcrypto"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var selfSignedValidationOnce sync.Once
func setupSelfSignedAPITest(t *testing.T) *gorm.DB {
t.Helper()
gin.SetMode(gin.TestMode)
selfSignedValidationOnce.Do(validation.Init)
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open test db: %v", err)
}
if err := db.AutoMigrate(&model.Cert{}); err != nil {
t.Fatalf("migrate test db: %v", err)
}
model.Use(db)
query.Use(db)
query.SetDefault(db)
return db
}
func TestSelfSignedSlugSanitizesPathTraversal(t *testing.T) {
if got := selfSignedSlug("../../etc"); got != "etc" {
t.Fatalf("selfSignedSlug() = %q, want %q", got, "etc")
}
}
func TestSelfSignedSlugFallsBackForEmptyInput(t *testing.T) {
if got := selfSignedSlug(""); got != defaultSelfSignedSlug {
t.Fatalf("selfSignedSlug() = %q, want %q", got, defaultSelfSignedSlug)
}
}
func TestSelfSignedSlugConvertsIDNToPunycode(t *testing.T) {
if got := selfSignedSlug("例如.test"); got != "xn--fsqu6v.test" {
t.Fatalf("selfSignedSlug() = %q, want %q", got, "xn--fsqu6v.test")
}
}
func TestBuildSelfSignedOptionsRejectsEmptySAN(t *testing.T) {
_, err := buildSelfSignedOptions(&SelfSignedCertRequest{
Domains: []string{" ", "\t"},
IPAddresses: []string{""},
})
if !errors.Is(err, cert.ErrSelfSignedNoSAN) {
t.Fatalf("buildSelfSignedOptions() error = %v, want %v", err, cert.ErrSelfSignedNoSAN)
}
}
func TestBuildSelfSignedOptionsNormalizesValues(t *testing.T) {
opts, err := buildSelfSignedOptions(&SelfSignedCertRequest{
Domains: []string{" example.com ", "", "www.example.com"},
IPAddresses: []string{" 127.0.0.1 "},
KeyType: string(certcrypto.EC256),
ValidityDays: 30,
})
if err != nil {
t.Fatalf("buildSelfSignedOptions() error = %v", err)
}
if opts.CommonName != "example.com" {
t.Fatalf("CommonName = %q, want %q", opts.CommonName, "example.com")
}
if len(opts.DNSNames) != 2 || opts.DNSNames[0] != "example.com" || opts.DNSNames[1] != "www.example.com" {
t.Fatalf("DNSNames = %#v, want normalized domains", opts.DNSNames)
}
if len(opts.IPAddresses) != 1 || opts.IPAddresses[0] != "127.0.0.1" {
t.Fatalf("IPAddresses = %#v, want normalized IP", opts.IPAddresses)
}
}
func TestGenerateSelfSignedCertRollsBackDBOnFileWriteFailure(t *testing.T) {
db := setupSelfSignedAPITest(t)
originalConfigDir := settings.NginxSettings.ConfigDir
blockedBase := filepath.Join(t.TempDir(), "nginx.conf.d")
if err := os.WriteFile(blockedBase, []byte("not a directory"), 0o644); err != nil {
t.Fatalf("write blocking file: %v", err)
}
settings.NginxSettings.ConfigDir = blockedBase
t.Cleanup(func() {
settings.NginxSettings.ConfigDir = originalConfigDir
})
router := gin.New()
router.POST("/self_signed_cert", GenerateSelfSignedCert)
body, err := json.Marshal(SelfSignedCertRequest{
Name: "rollback-test",
Domains: []string{"rollback.example"},
KeyType: string(certcrypto.EC256),
ValidityDays: 30,
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/self_signed_cert", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
if recorder.Code == http.StatusOK {
t.Fatalf("expected write failure response, got HTTP %d", recorder.Code)
}
var count int64
if err := db.Model(&model.Cert{}).Count(&count).Error; err != nil {
t.Fatalf("count cert rows: %v", err)
}
if count != 0 {
t.Fatalf("expected DB rollback to remove cert row, got %d rows", count)
}
}
func TestCleanupSelfSignedCertFilesRemovesManagedDirectory(t *testing.T) {
originalConfigDir := settings.NginxSettings.ConfigDir
confDir := t.TempDir()
settings.NginxSettings.ConfigDir = confDir
t.Cleanup(func() {
settings.NginxSettings.ConfigDir = originalConfigDir
})
certDir := filepath.Join(confDir, "ssl", "example_1")
if err := os.MkdirAll(certDir, 0o755); err != nil {
t.Fatalf("create cert dir: %v", err)
}
certPath := filepath.Join(certDir, "fullchain.cer")
keyPath := filepath.Join(certDir, "private.key")
if err := os.WriteFile(certPath, []byte("cert"), 0o644); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyPath, []byte("key"), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
cleanupSelfSignedCertFiles(&model.Cert{
AutoCert: model.AutoCertSelfSigned,
SSLCertificatePath: certPath,
SSLCertificateKeyPath: keyPath,
})
if _, err := os.Stat(certDir); !os.IsNotExist(err) {
t.Fatalf("expected managed cert directory to be removed, stat err: %v", err)
}
}
func TestGenerateSelfSignedCertRejectsEmptyName(t *testing.T) {
setupSelfSignedAPITest(t)
router := gin.New()
router.POST("/self_signed_cert", GenerateSelfSignedCert)
body, err := json.Marshal(SelfSignedCertRequest{
Domains: []string{"named.example"},
KeyType: string(certcrypto.EC256),
ValidityDays: 30,
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/self_signed_cert", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code < 400 || rec.Code >= 500 {
t.Fatalf("status = %d, want a 4xx for missing name", rec.Code)
}
if !strings.Contains(strings.ToLower(rec.Body.String()), "name") {
t.Fatalf("response body %q did not mention the missing name field", rec.Body.String())
}
}
+2
View File
@@ -137,6 +137,7 @@ declare module 'vue' {
SensitiveStringSensitiveInput: typeof import('./src/components/SensitiveString/SensitiveInput.vue')['default']
SensitiveStringSensitiveString: typeof import('./src/components/SensitiveString/SensitiveString.vue')['default']
SetLanguageSetLanguage: typeof import('./src/components/SetLanguage/SetLanguage.vue')['default']
StringListInputStringListInput: typeof import('./src/components/StringListInput/StringListInput.vue')['default']
SwitchAppearanceIconsVPIconMoon: typeof import('./src/components/SwitchAppearance/icons/VPIconMoon.vue')['default']
SwitchAppearanceIconsVPIconSun: typeof import('./src/components/SwitchAppearance/icons/VPIconSun.vue')['default']
SwitchAppearanceSwitchAppearance: typeof import('./src/components/SwitchAppearance/SwitchAppearance.vue')['default']
@@ -277,6 +278,7 @@ declare global {
const SensitiveStringSensitiveInput: typeof import('./src/components/SensitiveString/SensitiveInput.vue')['default']
const SensitiveStringSensitiveString: typeof import('./src/components/SensitiveString/SensitiveString.vue')['default']
const SetLanguageSetLanguage: typeof import('./src/components/SetLanguage/SetLanguage.vue')['default']
const StringListInputStringListInput: typeof import('./src/components/StringListInput/StringListInput.vue')['default']
const SwitchAppearanceIconsVPIconMoon: typeof import('./src/components/SwitchAppearance/icons/VPIconMoon.vue')['default']
const SwitchAppearanceIconsVPIconSun: typeof import('./src/components/SwitchAppearance/icons/VPIconSun.vue')['default']
const SwitchAppearanceSwitchAppearance: typeof import('./src/components/SwitchAppearance/SwitchAppearance.vue')['default']
+45 -2
View File
@@ -3,7 +3,8 @@ import type { AcmeUser } from '@/api/acme_user'
import type { ModelBase } from '@/api/curd'
import type { DnsCredential } from '@/api/dns_credential'
import type { PrivateKeyType } from '@/constants'
import { useCurdApi } from '@uozi-admin/request'
import { extendCurdApi, http, useCurdApi } from '@uozi-admin/request'
import { normalizePrivateKeyType, PrivateKeyTypeEnum } from '@/constants'
export const CertStatus = {
Pending: 'pending',
@@ -13,6 +14,11 @@ export const CertStatus = {
export type CertStatusType = '' | typeof CertStatus[keyof typeof CertStatus]
export interface SelfSignedCertConfig {
ip_addresses: string[]
validity_days: number
}
export interface Cert extends ModelBase {
name: string
domains: string[]
@@ -35,6 +41,7 @@ export interface Cert extends ModelBase {
status: CertStatusType
last_error: string
last_attempt_at: string | null
self_signed_config?: SelfSignedCertConfig
}
export interface CertificateInfo {
@@ -50,6 +57,42 @@ export interface CertificateResult {
key_type: PrivateKeyType
}
const cert = useCurdApi<Cert>('/certs')
export interface SelfSignedCertPayload {
name: string
domains: string[]
ip_addresses: string[]
key_type: string
validity_days: number
sync_node_ids?: number[]
}
// toSelfSignedPayload maps a persisted Cert to an editable self-signed payload.
export function toSelfSignedPayload(c: Cert): SelfSignedCertPayload {
const domains = c.domains?.length ? [...c.domains] : ['']
const ipAddresses = c.self_signed_config?.ip_addresses?.length
? [...c.self_signed_config.ip_addresses]
: ['']
// Backend stores key_type in its canonical form (EC256, RSA2048…); the
// form ASelect expects the legacy keys (P256, 2048…). Normalize so the
// option highlights correctly when editing an existing self-signed cert.
const keyType = normalizePrivateKeyType(c.key_type) || PrivateKeyTypeEnum.P256
return {
name: c.name ?? '',
domains,
ip_addresses: ipAddresses,
key_type: keyType,
validity_days: c.self_signed_config?.validity_days || 365,
sync_node_ids: [...(c.sync_node_ids ?? [])],
}
}
const cert = extendCurdApi(useCurdApi<Cert>('/certs'), {
generate_self_signed(payload: SelfSignedCertPayload): Promise<Cert> {
return http.post('/self_signed_cert', payload)
},
modify_self_signed(id: number, payload: SelfSignedCertPayload): Promise<Cert> {
return http.post(`/self_signed_cert/${id}`, payload)
},
})
export default cert
@@ -0,0 +1,51 @@
<script setup lang="ts">
defineProps<{
placeholder?: string
addButtonText?: string
}>()
const items = defineModel<string[]>({ required: true })
function addItem() {
items.value = [...items.value, '']
}
function removeItem(index: number) {
if (items.value.length <= 1)
return
const next = [...items.value]
next.splice(index, 1)
items.value = next
}
</script>
<template>
<div class="space-y-2">
<div
v-for="(_, index) in items"
:key="index"
class="flex items-center gap-2"
>
<AInput
v-model:value="items[index]"
:placeholder="placeholder"
class="flex-1"
/>
<AButton
v-if="items.length > 1"
type="link"
danger
:aria-label="`${$gettext('Remove')} ${index + 1}`"
@click="removeItem(index)"
>
{{ $gettext('Remove') }}
</AButton>
</div>
<AButton
block
@click="addItem"
>
{{ addButtonText ?? $gettext('Add Item') }}
</AButton>
</div>
</template>
@@ -0,0 +1,3 @@
import StringListInput from './StringListInput.vue'
export default StringListInput
+41
View File
@@ -10,6 +10,7 @@ export enum AutoCertState {
Disable = -1,
Enable = 1,
Sync = 2,
SelfSigned = 3,
}
export enum NotificationTypeT {
@@ -71,3 +72,43 @@ export const PrivateKeyTypeEnum = {
P256: 'P256',
P384: 'P384',
} as const
// Maps both legacy frontend keys (P256, 2048…) and canonical backend values
// (EC256, RSA2048…) to a single form key. The backend's helper.GetKeyType
// normalizes to the canonical form on write, so cert.key_type read from the
// API may be either form depending on when the row was written.
const PrivateKeyTypeAliasMap: Record<string, string> = {
2048: '2048',
RSA2048: '2048',
3072: '3072',
RSA3072: '3072',
4096: '4096',
RSA4096: '4096',
8192: '8192',
RSA8192: '8192',
P256: 'P256',
EC256: 'P256',
P384: 'P384',
EC384: 'P384',
}
// normalizePrivateKeyType collapses any accepted key_type form to the
// legacy frontend key, so it matches PrivateKeyTypeEnum / form ASelect
// options. Unknown values pass through unchanged.
export function normalizePrivateKeyType(value: string | undefined | null): string {
if (!value)
return ''
return PrivateKeyTypeAliasMap[value] ?? value
}
// formatPrivateKeyType returns the display label for any accepted form,
// falling back to '/' so table cells stay aligned with maskRender output
// when the value is missing or unknown.
export function formatPrivateKeyType(value: string | undefined | null): string {
if (!value)
return '/'
const normalized = PrivateKeyTypeAliasMap[value]
if (!normalized)
return '/'
return PrivateKeyTypeMask[normalized as PrivateKeyType]
}
+678 -581
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+389 -321
View File
File diff suppressed because it is too large Load Diff
+733 -629
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,15 @@
<script setup lang="ts">
import type { Ref } from 'vue'
import type { Cert } from '@/api/cert'
import cert from '@/api/cert'
import { AutoCertState } from '@/constants'
import type { Cert, SelfSignedCertPayload } from '@/api/cert'
import cert, { toSelfSignedPayload } from '@/api/cert'
import { AutoCertState, normalizePrivateKeyType } from '@/constants'
import AutoCertManagement from './components/AutoCertManagement.vue'
import CertificateActions from './components/CertificateActions.vue'
import CertificateBasicInfo from './components/CertificateBasicInfo.vue'
import CertificateContentEditor from './components/CertificateContentEditor.vue'
import CertificateDownload from './components/CertificateDownload.vue'
import SelfSignedCertManagement from './components/SelfSignedCertManagement.vue'
import { useCertStore } from './store'
const { message } = App.useApp()
@@ -28,10 +29,24 @@ const isManaged = computed(() => {
return data.value.auto_cert === AutoCertState.Enable || data.value.auto_cert === AutoCertState.Sync
})
const isSelfSigned = computed(() => {
return data.value.auto_cert === AutoCertState.SelfSigned
})
const selfSignedPayload = ref<SelfSignedCertPayload>()
watch(data, value => {
if (value.auto_cert === AutoCertState.SelfSigned)
selfSignedPayload.value = toSelfSignedPayload(value)
}, { immediate: true })
function init() {
if (id.value > 0) {
cert.getItem(id.value).then(r => {
data.value = r
// Backend stores key_type in its canonical form (EC256, RSA2048); the
// ACME form's ASelect options use the legacy keys (P256, 2048). Normalize
// on load so the dropdown highlights the right option when editing.
data.value = { ...r, key_type: normalizePrivateKeyType(r.key_type) }
})
}
else {
@@ -45,10 +60,43 @@ onMounted(() => {
async function save() {
try {
let savedId = data.value.id
if (isSelfSigned.value && selfSignedPayload.value && data.value.id) {
const payload = selfSignedPayload.value
const name = payload.name.trim()
const domains = payload.domains.map(d => d.trim()).filter(Boolean)
const ip_addresses = payload.ip_addresses.map(s => s.trim()).filter(Boolean)
if (!name) {
message.error($gettext('Please enter a name for the certificate'))
return
}
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
const currentId = data.value.id
const result = await cert.modify_self_signed(currentId, {
...payload,
name,
domains,
ip_addresses,
})
savedId = result.id || currentId
data.value = { ...result, id: savedId }
}
else {
await certStore.save()
savedId = data.value.id
}
if (!savedId) {
message.error($gettext('Saved certificate response is missing an ID'))
return
}
message.success($gettext('Save successfully'))
errors.value = {}
await router.push(`/certificates/${certStore.data.id}`)
await router.push(`/certificates/${savedId}`)
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
@@ -87,8 +135,16 @@ const log = computed(() => {
:sm="24"
:lg="12"
>
<!-- Self-signed Certificate Management -->
<SelfSignedCertManagement
v-if="isSelfSigned && selfSignedPayload"
v-model:value="selfSignedPayload"
:certificate-info="data.certificate_info"
/>
<!-- Auto Certificate Management -->
<AutoCertManagement
v-else
v-model:data="data"
:is-managed="isManaged"
@renewed="init"
@@ -97,6 +153,7 @@ const log = computed(() => {
<AForm layout="vertical">
<!-- Certificate Basic Information -->
<CertificateBasicInfo
v-if="!isSelfSigned"
v-model:data="data"
:errors="errors"
:is-managed="isManaged"
@@ -109,7 +166,7 @@ const log = computed(() => {
<CertificateContentEditor
v-model:data="data"
:errors="errors"
:readonly="isManaged"
:readonly="isManaged || isSelfSigned"
class="max-w-600px"
/>
</AForm>
@@ -1,9 +1,9 @@
import type { CustomRenderArgs, StdTableColumn } from '@uozi-admin/curd'
import type { JSXElements } from '@/types'
import { datetimeRender, maskRender } from '@uozi-admin/curd'
import { datetimeRender } from '@uozi-admin/curd'
import { Badge, Tag, Tooltip } from 'ant-design-vue'
import dayjs from 'dayjs'
import { PrivateKeyTypeMask } from '@/constants'
import { AutoCertState, formatPrivateKeyType } from '@/constants'
const columns: StdTableColumn[] = [{
title: () => $gettext('Name'),
@@ -28,20 +28,28 @@ const columns: StdTableColumn[] = [{
const sync = $gettext('Sync Certificate')
const managed = $gettext('Managed Certificate')
const general = $gettext('General Certificate')
if (text === true || text === 1) {
const selfSigned = $gettext('Self-signed Certificate')
if (text === true || text === AutoCertState.Enable) {
template.push(
<Tag bordered={false} color="processing">
{managed}
</Tag>,
)
}
else if (text === 2) {
else if (text === AutoCertState.Sync) {
template.push(
<Tag bordered={false} color="success">
{sync}
</Tag>,
)
}
else if (text === AutoCertState.SelfSigned) {
template.push(
<Tag bordered={false} color="cyan">
{selfSigned}
</Tag>,
)
}
else {
template.push(
<Tag bordered={false} color="purple">
@@ -56,7 +64,7 @@ const columns: StdTableColumn[] = [{
}, {
title: () => $gettext('Key Type'),
dataIndex: 'key_type',
customRender: maskRender(PrivateKeyTypeMask),
customRender: ({ text }: CustomRenderArgs) => formatPrivateKeyType(text),
sorter: true,
pure: true,
}, {
@@ -1,8 +1,13 @@
<script setup lang="ts">
import type { Ref } from 'vue'
import type { AutoCertOptions } from '@/api/auto_cert'
import type { SelfSignedCertPayload } from '@/api/cert'
import cert from '@/api/cert'
import AutoCertForm from '@/components/AutoCertForm'
import StringListInput from '@/components/StringListInput'
import { PrivateKeyTypeEnum } from '@/constants'
import ObtainCertLive from '@/views/site/site_edit/components/Cert/ObtainCertLive.vue'
import SelfSignedCertFields from './SelfSignedCertFields.vue'
const emit = defineEmits<{
issued: [void]
@@ -10,13 +15,29 @@ const emit = defineEmits<{
const { message } = App.useApp()
type CertType = 'wildcard' | 'custom' | 'self_signed'
const step = ref(0)
const visible = ref(false)
const data = ref({}) as Ref<AutoCertOptions>
const domain = ref('')
const certType = ref<'wildcard' | 'custom'>('wildcard')
const certType = ref<CertType>('wildcard')
const customDomains = ref<string[]>([''])
const errored = ref(false)
const selfSignedLoading = ref(false)
function emptySelfSignedPayload(): SelfSignedCertPayload {
return {
name: '',
domains: [''],
ip_addresses: [''],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
const selfSignedPayload = ref<SelfSignedCertPayload>(emptySelfSignedPayload())
function open() {
visible.value = true
@@ -29,6 +50,7 @@ function open() {
certType.value = 'wildcard'
customDomains.value = ['']
errored.value = false
selfSignedPayload.value = emptySelfSignedPayload()
}
defineExpose({
@@ -62,16 +84,6 @@ const computedMainDomain = computed(() => {
}
})
function addCustomDomain() {
customDomains.value.push('')
}
function removeCustomDomain(index: number) {
if (customDomains.value.length > 1) {
customDomains.value.splice(index, 1)
}
}
function issueCert() {
if (!data.value.dns_credential_id) {
message.error($gettext('Please select a DNS credential'))
@@ -102,6 +114,41 @@ function issueCert() {
errored.value = true
})
}
async function submitSelfSigned() {
const name = selfSignedPayload.value.name.trim()
const domains = selfSignedPayload.value.domains.map(d => d.trim()).filter(Boolean)
const ip_addresses = selfSignedPayload.value.ip_addresses.map(s => s.trim()).filter(Boolean)
if (!name) {
message.error($gettext('Please enter a name for the certificate'))
return
}
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
selfSignedLoading.value = true
try {
await cert.generate_self_signed({
...selfSignedPayload.value,
name,
domains,
ip_addresses,
})
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('issued')
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
selfSignedLoading.value = false
}
}
</script>
<template>
@@ -126,6 +173,9 @@ function issueCert() {
<ASelectOption value="custom">
{{ $gettext('Custom Domains Certificate') }}
</ASelectOption>
<ASelectOption value="self_signed">
{{ $gettext('Self-signed Certificate') }}
</ASelectOption>
</ASelect>
</AFormItem>
@@ -139,36 +189,13 @@ function issueCert() {
</AFormItem>
</template>
<template v-else>
<template v-else-if="certType === 'custom'">
<AFormItem :label="$gettext('Custom Domains')">
<div class="space-y-2">
<div
v-for="(_, index) in customDomains"
:key="index"
class="flex items-center gap-2"
>
<AInput
v-model:value="customDomains[index]"
<StringListInput
v-model="customDomains"
:placeholder="$gettext('Enter domain name')"
class="flex-1"
:add-button-text="$gettext('Add Domain')"
/>
<AButton
v-if="customDomains.length > 1"
type="link"
danger
@click="removeCustomDomain(index)"
>
{{ $gettext('Remove') }}
</AButton>
</div>
<AButton
block
@click="addCustomDomain"
>
{{ $gettext('Add Domain') }}
</AButton>
</div>
<AAlert
:message="$gettext('All selected subdomains must belong to the same DNS Provider, otherwise the certificate application will fail.')"
type="info"
@@ -180,6 +207,7 @@ function issueCert() {
</template>
</AForm>
<template v-if="certType !== 'self_signed'">
<AutoCertForm
v-model:options="data"
style="max-width: 600px"
@@ -187,10 +215,7 @@ function issueCert() {
force-dns-challenge
/>
<div
v-if="step === 0"
class="flex justify-end"
>
<div class="flex justify-end">
<AButton
type="primary"
@click="issueCert"
@@ -200,6 +225,21 @@ function issueCert() {
</div>
</template>
<template v-else>
<SelfSignedCertFields v-model="selfSignedPayload" />
<div class="flex justify-end">
<AButton
type="primary"
:loading="selfSignedLoading"
@click="submitSelfSigned"
>
{{ $gettext('Generate') }}
</AButton>
</div>
</template>
</template>
<ObtainCertLive
v-show="step === 1"
ref="refObtainCertLive"
@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { SelfSignedCertPayload } from '@/api/cert'
import NodeSelector from '@/components/NodeSelector'
import StringListInput from '@/components/StringListInput'
import { PrivateKeyTypeList } from '@/constants'
const props = defineProps<{
isKeyTypeReadonly?: boolean
hideRenewalNote?: boolean
}>()
const data = defineModel<SelfSignedCertPayload>({ required: true })
</script>
<template>
<AForm
layout="vertical"
class="max-w-[600px]"
>
<AAlert
v-if="!props.hideRenewalNote"
class="mb-4"
type="info"
show-icon
:message="$gettext('Nginx UI will automatically renew this certificate as it approaches expiration, based on the global certificate renewal interval and this certificate\'s validity period.')"
/>
<AFormItem
:label="$gettext('Name')"
required
>
<AInput
v-model:value="data.name"
:placeholder="$gettext('Enter certificate name')"
/>
</AFormItem>
<AFormItem :label="$gettext('Domains')">
<StringListInput
v-model="data.domains"
:placeholder="$gettext('Enter domain name')"
:add-button-text="$gettext('Add Domain')"
/>
</AFormItem>
<AFormItem :label="$gettext('IP Addresses')">
<StringListInput
v-model="data.ip_addresses"
:placeholder="$gettext('Enter IP address')"
:add-button-text="$gettext('Add IP Address')"
/>
</AFormItem>
<AFormItem :label="$gettext('Key Type')">
<ASelect
v-model:value="data.key_type"
:disabled="props.isKeyTypeReadonly"
>
<ASelectOption
v-for="t in PrivateKeyTypeList"
:key="t.key"
:value="t.key"
>
{{ t.name }}
</ASelectOption>
</ASelect>
</AFormItem>
<AFormItem :label="$gettext('Valid For (days)')">
<AInputNumber
v-model:value="data.validity_days"
:min="1"
:max="3650"
class="w-full"
/>
<template #help>
{{ $gettext('Some browsers reject TLS certificates valid for more than 398 days.') }}
</template>
</AFormItem>
<AFormItem :label="$gettext('Sync to')">
<NodeSelector
v-model:target="data.sync_node_ids"
hidden-local
/>
</AFormItem>
</AForm>
</template>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { Cert, SelfSignedCertPayload } from '@/api/cert'
import cert from '@/api/cert'
import { PrivateKeyTypeEnum } from '@/constants'
import SelfSignedCertFields from './SelfSignedCertFields.vue'
const props = defineProps<{
defaultDomains?: string[]
}>()
const emit = defineEmits<{
created: [cert: Cert]
}>()
const { message } = App.useApp()
const visible = ref(false)
const loading = ref(false)
function emptyForm(): SelfSignedCertPayload {
const defaultDomains = props.defaultDomains ?? []
return {
name: '',
domains: defaultDomains.length ? [...defaultDomains] : [''],
ip_addresses: [''],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
const form = ref<SelfSignedCertPayload>(emptyForm())
function open() {
form.value = emptyForm()
visible.value = true
}
defineExpose({ open })
async function submit() {
const name = form.value.name.trim()
const domains = form.value.domains.map(d => d.trim()).filter(Boolean)
const ip_addresses = form.value.ip_addresses.map(s => s.trim()).filter(Boolean)
if (!name) {
message.error($gettext('Please enter a name for the certificate'))
return
}
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
loading.value = true
try {
const created = await cert.generate_self_signed({
...form.value,
name,
domains,
ip_addresses,
})
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('created', created)
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
loading.value = false
}
}
</script>
<template>
<AModal
v-model:open="visible"
:title="$gettext('Generate Self-signed Certificate')"
:confirm-loading="loading"
:ok-text="$gettext('Generate')"
:width="600"
destroy-on-close
@ok="submit"
>
<SelfSignedCertFields v-model="form" />
</AModal>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { CertificateInfo, SelfSignedCertPayload } from '@/api/cert'
import CertInfo from '@/components/CertInfo'
import SelfSignedCertFields from './SelfSignedCertFields.vue'
defineProps<{
certificateInfo?: CertificateInfo
}>()
const data = defineModel<SelfSignedCertPayload>('value', { required: true })
</script>
<template>
<div class="self-signed-cert-management mb-4">
<AAlert
class="mb-4"
:message="$gettext('This self-signed certificate is managed by Nginx UI and renewed automatically.')"
type="success"
show-icon
/>
<AForm
v-if="certificateInfo"
layout="vertical"
>
<AFormItem :label="$gettext('Certificate Status')">
<CertInfo
:cert="certificateInfo"
class="max-w-96"
/>
</AFormItem>
</AForm>
<SelfSignedCertFields
v-model="data"
is-key-type-readonly
hide-renewal-note
/>
</div>
</template>
@@ -6,6 +6,7 @@ import { ConfigStatus } from '@/constants'
import { useSiteEditorStore } from '../SiteEditor/store'
import ChangeCert from './ChangeCert.vue'
import IssueCert from './IssueCert.vue'
import SelfSignedCert from './SelfSignedCert.vue'
const props = defineProps<{
configName: string
@@ -97,6 +98,9 @@ function handleCertChange(certs: Cert[]) {
v-if="siteStatus === ConfigStatus.Enabled || siteStatus === ConfigStatus.Maintenance"
:config-name
/>
<SelfSignedCert
v-if="siteStatus === ConfigStatus.Enabled || siteStatus === ConfigStatus.Maintenance"
/>
</div>
</template>
@@ -7,6 +7,7 @@ import { AutoCertChallengeMethod } from '@/api/auto_cert'
import site from '@/api/site'
import AutoCertStepOne from '@/components/AutoCertForm'
import { PrivateKeyTypeEnum } from '@/constants'
import { useTLSDirectives } from '../../composables/useTLSDirectives'
import { useSiteEditorStore } from '../SiteEditor/store'
import ObtainCertLive from './ObtainCertLive.vue'
@@ -17,7 +18,7 @@ const props = defineProps<{
const editorStore = useSiteEditorStore()
const { message } = useGlobalApp()
const { ngxConfig, issuingCert, curServerDirectives, curDirectivesMap, isDefaultServer, hasWildcardServerName, hasExplicitIpAddress, isIpCertificate, needsManualIpInput } = storeToRefs(editorStore)
const { ngxConfig, issuingCert, curDirectivesMap, isDefaultServer, hasWildcardServerName, hasExplicitIpAddress, isIpCertificate, needsManualIpInput } = storeToRefs(editorStore)
const autoCert = defineModel<boolean>('autoCert')
@@ -46,59 +47,7 @@ const name = computed(() => {
const refObtainCertLive = useTemplateRef('refObtainCertLive')
const refAutoCertForm = useTemplateRef('refAutoCertForm')
function hasTLSListen(params: string) {
return params.includes('443') && params.includes('ssl')
}
function ensureDirective(directive: string, params: string, insertIndex?: number) {
if (!curServerDirectives.value)
curServerDirectives.value = []
const existingDirective = curServerDirectives.value.find(v => v.directive === directive)
if (existingDirective) {
existingDirective.params = params
return
}
const directiveItem = { directive, params }
if (insertIndex === undefined || insertIndex < 0 || insertIndex > curServerDirectives.value.length) {
curServerDirectives.value.push(directiveItem)
return
}
curServerDirectives.value.splice(insertIndex, 0, directiveItem)
}
function ensureTLSDirectives(sslCertificate: string, sslCertificateKey: string) {
if (!curServerDirectives.value)
curServerDirectives.value = []
const hasIPv4TLSListen = curServerDirectives.value.some(v => v.directive === 'listen' && hasTLSListen(v.params) && !v.params.includes('[::]'))
const hasIPv6TLSListen = curServerDirectives.value.some(v => v.directive === 'listen' && hasTLSListen(v.params) && v.params.includes('[::]'))
if (!hasIPv6TLSListen) {
curServerDirectives.value.splice(0, 0, {
directive: 'listen',
params: '[::]:443 ssl',
})
}
if (!hasIPv4TLSListen) {
curServerDirectives.value.splice(0, 0, {
directive: 'listen',
params: '443 ssl',
})
}
const serverNameIdx = curDirectivesMap.value.server_name?.[0]?.idx ?? (curServerDirectives.value.length - 1)
ensureDirective('ssl_certificate', sslCertificate, serverNameIdx + 1)
const sslCertificateIndex = curServerDirectives.value.findIndex(v => v.directive === 'ssl_certificate')
ensureDirective('ssl_certificate_key', sslCertificateKey, sslCertificateIndex + 1)
}
const { ensureTLSDirectives } = useTLSDirectives()
function issueCert() {
const live = refObtainCertLive.value
@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { Cert } from '@/api/cert'
import { Modal } from 'ant-design-vue'
import SelfSignedCertForm from '@/views/certificate/components/SelfSignedCertForm.vue'
import { useTLSDirectives } from '../../composables/useTLSDirectives'
import { useSiteEditorStore } from '../SiteEditor/store'
const editorStore = useSiteEditorStore()
const { curDirectivesMap } = storeToRefs(editorStore)
const { ensureTLSDirectives } = useTLSDirectives()
const { message } = useGlobalApp()
const [modal, ContextHolder] = Modal.useModal()
const refForm = useTemplateRef('refForm')
const serverNames = computed(() => {
const params = curDirectivesMap.value.server_name?.[0]?.params?.trim()
return params ? params.split(/\s+/) : []
})
function open() {
refForm.value?.open()
}
function onCreated(certificate: Cert) {
// Write the TLS directives into the editor first so the user can see the
// pending diff regardless of whether they choose to save now or review.
ensureTLSDirectives(certificate.ssl_certificate_path, certificate.ssl_certificate_key_path)
modal.confirm({
title: $gettext('Save the site configuration now?'),
content: $gettext(
'The certificate has been generated at %{path} and the ssl_certificate '
+ 'directives have been added to the current server block. Save the '
+ 'configuration now, or review the changes in the editor and save manually.',
{ path: certificate.ssl_certificate_path },
),
okText: $gettext('Save now'),
cancelText: $gettext('Review first'),
centered: true,
async onOk() {
try {
await editorStore.save()
message.success($gettext('Self-signed certificate applied'))
}
catch {
message.error($gettext(
'Saving the site configuration failed; the certificate directives are in '
+ 'the editor — review the changes and retry from the Save button.',
))
}
},
onCancel() {
message.info($gettext('Certificate directives added to the editor; review and save when ready.'))
},
})
}
</script>
<template>
<div class="self-signed-cert">
<ContextHolder />
<AFormItem :label="$gettext('Self-signed certificate')">
<AButton @click="open">
{{ $gettext('Generate self-signed certificate') }}
</AButton>
</AFormItem>
<SelfSignedCertForm
ref="refForm"
:default-domains="serverNames"
@created="onCreated"
/>
</div>
</template>
<style scoped lang="less">
.self-signed-cert {
margin: 15px 0;
}
</style>
@@ -0,0 +1,78 @@
import { storeToRefs } from 'pinia'
import { useSiteEditorStore } from '../components/SiteEditor/store'
function getListenPort(params: string) {
const firstToken = params.trim().split(/\s+/)[0]?.replace(/;$/, '')
if (!firstToken)
return ''
const ipv6Port = firstToken.match(/^\[[^\]]+\]:(\d+)$/)
if (ipv6Port)
return ipv6Port[1]
const port = firstToken.match(/(?:^|:)(\d+)$/)
return port?.[1] ?? ''
}
export function hasTLSListen(params: string) {
const tokens = params.trim().split(/\s+/).map(token => token.replace(/;$/, ''))
return getListenPort(params) === '443' && tokens.includes('ssl')
}
export function isIPv6Listen(params: string) {
return params.trim().startsWith('[')
}
// useTLSDirectives provides helpers that write SSL directives into the
// currently edited server block.
export function useTLSDirectives() {
const editorStore = useSiteEditorStore()
const { curServerDirectives, curDirectivesMap } = storeToRefs(editorStore)
function ensureDirective(directive: string, params: string, insertIndex?: number) {
if (!curServerDirectives.value)
curServerDirectives.value = []
const existingDirective = curServerDirectives.value.find(v => v.directive === directive)
if (existingDirective) {
existingDirective.params = params
return
}
const directiveItem = { directive, params }
if (insertIndex === undefined || insertIndex < 0 || insertIndex > curServerDirectives.value.length) {
curServerDirectives.value.push(directiveItem)
return
}
curServerDirectives.value.splice(insertIndex, 0, directiveItem)
}
function ensureTLSDirectives(sslCertificate: string, sslCertificateKey: string) {
if (!curServerDirectives.value)
curServerDirectives.value = []
const hasIPv4TLSListen = curServerDirectives.value.some(v => v.directive === 'listen' && hasTLSListen(v.params) && !isIPv6Listen(v.params))
const hasIPv6TLSListen = curServerDirectives.value.some(v => v.directive === 'listen' && hasTLSListen(v.params) && isIPv6Listen(v.params))
if (!hasIPv6TLSListen) {
curServerDirectives.value.splice(0, 0, {
directive: 'listen',
params: '[::]:443 ssl',
})
}
if (!hasIPv4TLSListen) {
curServerDirectives.value.splice(0, 0, {
directive: 'listen',
params: '443 ssl',
})
}
const serverNameIdx = curDirectivesMap.value.server_name?.[0]?.idx ?? (curServerDirectives.value.length - 1)
ensureDirective('ssl_certificate', sslCertificate, serverNameIdx + 1)
const sslCertificateIndex = curServerDirectives.value.findIndex(v => v.directive === 'ssl_certificate')
ensureDirective('ssl_certificate_key', sslCertificateKey, sslCertificateIndex + 1)
}
return { ensureTLSDirectives }
}
@@ -0,0 +1,559 @@
# Merge Self-signed Certificate into Issue Certificate Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Collapse the certificate list header from three actions to two by absorbing the standalone "Self-signed Certificate" entry into the existing "Issue Certificate" dialog as a third Certificate Type option.
**Architecture:** Two-file frontend refactor. `DNSIssueCertificate.vue` gains a `'self_signed'` value for its `certType` state, renders `SelfSignedCertFields` instead of the ACME form when selected, and calls `cert.generate_self_signed()` on submit. `CertificateList/Certificate.vue` drops the standalone button, ref, handler, and `SelfSignedCertForm` mount.
**Tech Stack:** Vue 3 (`<script setup lang="ts">`), Ant Design Vue (`AModal`, `AForm`, `AButton`, `ASelect`), the project's `$gettext` i18n helper, and the existing `cert` API client at `app/src/api/cert.ts`.
**Spec:** [`docs/superpowers/specs/2026-05-23-merge-self-signed-into-issue-cert-design.md`](../specs/2026-05-23-merge-self-signed-into-issue-cert-design.md)
---
## Files touched
- Modify: `app/src/views/certificate/components/DNSIssueCertificate.vue`
- Modify: `app/src/views/certificate/CertificateList/Certificate.vue`
Untouched (deliberately — see spec):
- `app/src/views/certificate/components/SelfSignedCertForm.vue` (still used by site editor)
- `app/src/views/certificate/components/SelfSignedCertFields.vue` (reused as the field set)
- `app/src/views/certificate/components/SelfSignedCertManagement.vue`
- `app/src/views/site/site_edit/components/Cert/SelfSignedCert.vue`
No backend changes, no Go tests.
---
## Project conventions reminder
- Frontend stack: pnpm only, Vue 3 Composition API with `<script setup>`, TypeScript, Ant Design Vue, UnoCSS.
- Code quality gates: `pnpm lint`, `pnpm lint:fix`, `pnpm typecheck` must all pass before commit.
- Vue auto-imports are configured in this project: `ref`, `computed`, `watch`, `App`, `$gettext` etc. don't need explicit imports — match the surrounding file's style.
- Comments must be in English.
- Commits: short imperative subject; sign off with `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>` (project convention).
---
### Task 1: Extend DNSIssueCertificate with self-signed support
**Files:**
- Modify: `app/src/views/certificate/components/DNSIssueCertificate.vue`
Two cooperating template branches need to coexist:
1. The existing `template v-else` for `customDomains` currently triggers on any value that isn't `'wildcard'`. After we add `'self_signed'`, that `v-else` would wrongly render the Custom Domains input for self-signed mode. We must change it to `v-else-if="certType === 'custom'"`.
2. The `AutoCertForm` + Next button block must hide when `certType === 'self_signed'`.
3. A new `SelfSignedCertFields` + Generate button block must render when `certType === 'self_signed'`.
- [ ] **Step 1: Update script imports and state**
Replace the entire `<script setup lang="ts">` block (lines 1105) with this:
```vue
<script setup lang="ts">
import type { Ref } from 'vue'
import type { AutoCertOptions } from '@/api/auto_cert'
import type { SelfSignedCertPayload } from '@/api/cert'
import AutoCertForm from '@/components/AutoCertForm'
import cert from '@/api/cert'
import { PrivateKeyTypeEnum } from '@/constants'
import ObtainCertLive from '@/views/site/site_edit/components/Cert/ObtainCertLive.vue'
import SelfSignedCertFields from './SelfSignedCertFields.vue'
const emit = defineEmits<{
issued: [void]
}>()
const { message } = App.useApp()
type CertType = 'wildcard' | 'custom' | 'self_signed'
const step = ref(0)
const visible = ref(false)
const data = ref({}) as Ref<AutoCertOptions>
const domain = ref('')
const certType = ref<CertType>('wildcard')
const customDomains = ref<string[]>([''])
const errored = ref(false)
const selfSignedLoading = ref(false)
function emptySelfSignedPayload(): SelfSignedCertPayload {
return {
name: '',
domains: [],
ip_addresses: [],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
const selfSignedPayload = ref<SelfSignedCertPayload>(emptySelfSignedPayload())
function open() {
visible.value = true
step.value = 0
data.value = {
challenge_method: 'dns01',
key_type: 'P256',
} as AutoCertOptions
domain.value = ''
certType.value = 'wildcard'
customDomains.value = ['']
errored.value = false
selfSignedPayload.value = emptySelfSignedPayload()
}
defineExpose({
open,
})
const modalVisible = ref(false)
const modalClosable = ref(true)
const refObtainCertLive = useTemplateRef('refObtainCertLive')
const computedDomain = computed(() => {
return `*.${domain.value}`
})
const computedDomains = computed(() => {
if (certType.value === 'wildcard') {
return [computedDomain.value, domain.value]
}
else {
return customDomains.value.filter(d => d.trim())
}
})
const computedMainDomain = computed(() => {
if (certType.value === 'wildcard') {
return computedDomain.value
}
else {
return customDomains.value.find(d => d.trim()) || ''
}
})
function addCustomDomain() {
customDomains.value.push('')
}
function removeCustomDomain(index: number) {
if (customDomains.value.length > 1) {
customDomains.value.splice(index, 1)
}
}
function issueCert() {
if (!data.value.dns_credential_id) {
message.error($gettext('Please select a DNS credential'))
return
}
if (certType.value === 'custom') {
const validDomains = customDomains.value.filter(d => d.trim())
if (validDomains.length === 0) {
message.error($gettext('Please enter at least one domain'))
return
}
}
errored.value = false
step.value = 1
modalVisible.value = true
// 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('Issued successfully'))
emit('issued')
})
.catch(() => {
errored.value = true
})
}
async function submitSelfSigned() {
const { domains, ip_addresses } = selfSignedPayload.value
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
selfSignedLoading.value = true
try {
await cert.generate_self_signed(selfSignedPayload.value)
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('issued')
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
selfSignedLoading.value = false
}
}
</script>
```
Key script-side changes vs. the original:
- Added imports: `SelfSignedCertPayload` type, `cert` API client, `PrivateKeyTypeEnum` constant, `SelfSignedCertFields` component.
- Added `CertType` union, `selfSignedLoading`, `emptySelfSignedPayload()`, `selfSignedPayload` ref.
- `open()` resets `selfSignedPayload`.
- New `submitSelfSigned()` function.
- [ ] **Step 2: Update the Certificate Type select**
In the `<template>` block, modify the `<ASelect v-model:value="certType">` (lines ~122129) to add the third option. Replace:
```vue
<ASelect v-model:value="certType">
<ASelectOption value="wildcard">
{{ $gettext('Wildcard Certificate') }}
</ASelectOption>
<ASelectOption value="custom">
{{ $gettext('Custom Domains Certificate') }}
</ASelectOption>
</ASelect>
```
with:
```vue
<ASelect v-model:value="certType">
<ASelectOption value="wildcard">
{{ $gettext('Wildcard Certificate') }}
</ASelectOption>
<ASelectOption value="custom">
{{ $gettext('Custom Domains Certificate') }}
</ASelectOption>
<ASelectOption value="self_signed">
{{ $gettext('Self-signed Certificate') }}
</ASelectOption>
</ASelect>
```
- [ ] **Step 3: Scope the custom-domain branch to `'custom'` only**
The current `<template v-else>` (line ~142) will fire for `'self_signed'` too — change it to an explicit conditional. Replace:
```vue
<template v-else>
<AFormItem :label="$gettext('Custom Domains')">
```
with:
```vue
<template v-else-if="certType === 'custom'">
<AFormItem :label="$gettext('Custom Domains')">
```
(Only the opening tag changes; nothing else in that branch needs editing.)
- [ ] **Step 4: Hide AutoCertForm + Next button in self-signed mode and add the self-signed field set + Generate button**
Find this block (originally lines ~183200):
```vue
<AutoCertForm
v-model:options="data"
style="max-width: 600px"
hide-note
force-dns-challenge
/>
<div
v-if="step === 0"
class="flex justify-end"
>
<AButton
type="primary"
@click="issueCert"
>
{{ $gettext('Next') }}
</AButton>
</div>
```
Replace it with:
```vue
<template v-if="certType !== 'self_signed'">
<AutoCertForm
v-model:options="data"
style="max-width: 600px"
hide-note
force-dns-challenge
/>
<div class="flex justify-end">
<AButton
type="primary"
@click="issueCert"
>
{{ $gettext('Next') }}
</AButton>
</div>
</template>
<template v-else>
<SelfSignedCertFields v-model="selfSignedPayload" />
<div class="flex justify-end">
<AButton
type="primary"
:loading="selfSignedLoading"
@click="submitSelfSigned"
>
{{ $gettext('Generate') }}
</AButton>
</div>
</template>
```
Two intentional simplifications versus the original:
- Dropped the inner `v-if="step === 0"` on the Next-button div — the whole block is already inside `<template v-if="step === 0">` at the outer level (line ~119), so the inner guard was always true.
- Generate button has no surrounding `v-if="step === 0"` for the same reason.
- [ ] **Step 5: Verify lint and types**
Run from `app/`:
```bash
pnpm lint && pnpm typecheck
```
Expected: both commands exit 0. If `pnpm lint` flags style issues, run `pnpm lint:fix` and re-run `pnpm lint`.
- [ ] **Step 6: Commit**
```bash
git add app/src/views/certificate/components/DNSIssueCertificate.vue
git commit -m "$(cat <<'EOF'
feat(cert): add self-signed option in issue certificate dialog
Extend the Issue Certificate dialog's Certificate Type select with a
"Self-signed" option that swaps the form body to SelfSignedCertFields
and routes submission through cert.generate_self_signed(). ACME paths
(Wildcard / Custom Domains) are unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: Remove the standalone Self-signed Certificate button from the list header
**Files:**
- Modify: `app/src/views/certificate/CertificateList/Certificate.vue`
After Task 1 the Issue Certificate dialog already covers the self-signed flow. Now we remove the duplicate header entry.
- [ ] **Step 1: Replace the file's `<script setup>` block**
Replace lines 125 (the entire `<script setup lang="tsx">` block) with:
```vue
<script setup lang="tsx">
import { CloudUploadOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
import { StdTable } from '@uozi-admin/curd'
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()
const refTable = ref()
const globalStore = useGlobalStore()
const { processingStatus } = storeToRefs(globalStore)
</script>
```
Removed:
- `import type { Cert } from '@/api/cert'` (only `onSelfSignedCreated` used it).
- `SafetyOutlined` from the icons import (drop just that name, keep the other two).
- `SelfSignedCertForm` import.
- `refSelfSigned` ref.
- `router` and `onSelfSignedCreated` (no longer needed — the dialog's `issued` emit drives the refresh).
- [ ] **Step 2: Remove the Self-signed button and modal mount from the template**
Replace the `<template>` block (lines 2791) with:
```vue
<template>
<ACard :title="$gettext('Certificates')">
<template #extra>
<AButton
type="link"
size="small"
@click="$router.push('/certificates/import')"
>
<CloudUploadOutlined />
{{ $gettext('Import') }}
</AButton>
<AButton
type="link"
size="small"
:disabled="processingStatus.auto_cert_processing"
@click="() => refWildcard.open()"
>
<SafetyCertificateOutlined />
{{ $gettext('Issue certificate') }}
</AButton>
</template>
<StdTable
ref="refTable"
:api="cert"
:columns="certColumns"
:get-list-api="cert.getList"
disable-view
:scroll-x="1000"
disable-delete
@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"
:disabled="processingStatus.auto_cert_processing"
@removed="() => refTable.refresh()"
/>
</template>
</StdTable>
<WildcardCertificate
ref="refWildcard"
@issued="() => refTable.refresh()"
/>
</ACard>
</template>
```
Removed:
- The `<AButton>` with `<SafetyOutlined />` and the Self-signed Certificate text.
- The `<SelfSignedCertForm ref="refSelfSigned" @created="onSelfSignedCreated" />` block at the bottom.
The `<style lang="less" scoped>` block at the end of the file is unchanged.
- [ ] **Step 3: Verify lint and types**
Run from `app/`:
```bash
pnpm lint && pnpm typecheck
```
Expected: both exit 0. The removed imports must be cleanly removed — leftover `Cert` or `SafetyOutlined` references would surface as lint warnings here.
- [ ] **Step 4: Commit**
```bash
git add app/src/views/certificate/CertificateList/Certificate.vue
git commit -m "$(cat <<'EOF'
refactor(cert): drop standalone self-signed button from list header
Certificate creation is now consolidated under the Issue Certificate
dialog (which exposes Self-signed as a Certificate Type option), so
the duplicate header entry, its ref, handler, and modal mount are
removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 3: Manual verification
**Files:** none modified.
These are the steps to convince yourself the change works end-to-end before declaring done. No automation; the project doesn't carry frontend component tests for this view.
- [ ] **Step 1: Start the frontend dev server**
From `app/`:
```bash
pnpm install
pnpm run dev
```
Expected: Vite reports ready and prints a local URL.
- [ ] **Step 2: Open the certificate list page**
Navigate to the Certificates page in the running UI.
Expected: the card header shows exactly two action buttons: **Import** and **Issue certificate**. The standalone Self-signed Certificate button is gone.
- [ ] **Step 3: Verify Wildcard ACME flow still works (no regression)**
Click **Issue certificate**. The Certificate Type select defaults to `Wildcard Certificate`. Fill in a domain, pick a DNS credential, click **Next**. The dialog should advance to the ObtainCertLive step exactly as before.
(You don't need to actually complete an ACME issuance unless you have a credential handy — the goal is just to confirm the form still renders and Next triggers the live step.)
- [ ] **Step 4: Verify Custom Domains flow still works (no regression)**
Re-open the dialog, switch the select to `Custom Domains Certificate`. Confirm the custom-domain list, Add Domain button, and Remove buttons all render. The DNS provider alert below should still show.
- [ ] **Step 5: Verify the new Self-signed flow**
Re-open the dialog, switch the select to `Self-signed Certificate`.
Expected:
- The ACME form (Challenge Method, ACME User, DNS provider, OCSP, Revoke Old) disappears.
- The self-signed field set appears: Name, Domains (tag input), IP Addresses (tag input), Key Type, Valid For (days), Sync to.
- The bottom button reads **Generate**.
Click **Generate** with both Domains and IP Addresses empty.
Expected: red error toast "Please enter at least one domain or IP address". Dialog stays open.
Enter at least one domain (e.g. `local.test`) and click **Generate**.
Expected: green toast "Self-signed certificate generated". Dialog closes. The new row appears in the table.
- [ ] **Step 6: Verify site editor self-signed shortcut still works (regression)**
Open any site in the editor. In the Cert panel, click the self-signed shortcut (`SelfSignedCert.vue`).
Expected: its own modal opens and works exactly as before — the refactor must not have affected this code path.
- [ ] **Step 7: No commit**
Manual verification produces no files to commit.
---
## Wrap-up checklist
After both code commits land and manual verification passes:
- [ ] `pnpm lint` and `pnpm typecheck` both green on the final tree.
- [ ] `git status` is clean.
- [ ] The two commits are scoped one per task (creation extension, then header cleanup) and reviewable independently.
- [ ] No backend files touched; no Go tests need re-running.
@@ -0,0 +1,942 @@
# Self-signed Certificate UX Enhancements Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** (1) Extract the Custom Domains row-list editor into a shared `StringListInput` component and reuse it for the self-signed Domains / IP Addresses fields. (2) Show a renewal-policy hint in the self-signed form. (3) Require a non-empty Name for self-signed certificates on both client and server.
**Architecture:** New `StringListInput` lives in `app/src/components/`. `SelfSignedCertFields` gains a `hideRenewalNote` prop and required Name. Payload factories in three locations seed an empty editable row; three submit/save paths trim and filter before calling the API and now reject empty Name. Backend gains `binding:"required"` on `SelfSignedCertRequest.Name` and one new Go test.
**Tech Stack:** Vue 3 `<script setup lang="ts">`, Ant Design Vue, UnoCSS, project's `$gettext` helper. Go / Gin / Cosy for the backend; existing `gin.New` + `httptest` test pattern.
**Spec:** [`docs/superpowers/specs/2026-05-23-self-signed-enhancements-design.md`](../specs/2026-05-23-self-signed-enhancements-design.md)
**Prior context:** Builds on commits `605c6fed1` and `19776d442` on the `feature/self-signed-certificate` branch.
---
## Project conventions
- Frontend: pnpm only. Lint/typecheck gates run from `app/`: `pnpm lint`, `pnpm lint:fix`, `pnpm typecheck`. The `perfectionist` rule may reorder imports during `lint:fix` — accept it.
- Vue auto-imports: `ref`, `computed`, `watch`, `App.useApp`, `$gettext`, `useTemplateRef`, `storeToRefs`. Match surrounding style.
- All comments / i18n strings in English.
- Backend: `gofmt`/`goimports` clean, `go test ./... -race -cover` for full sweep but only the touched packages need to pass.
- Commits: imperative subject, sign-off:
`Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`
- Branch: `feature/self-signed-certificate`. Commit directly; do not branch.
---
## File map
New:
- `app/src/components/StringListInput/StringListInput.vue`
- `app/src/components/StringListInput/index.ts`
Modified:
- `app/src/views/certificate/components/SelfSignedCertFields.vue`
- `app/src/views/certificate/components/SelfSignedCertForm.vue`
- `app/src/views/certificate/components/SelfSignedCertManagement.vue`
- `app/src/views/certificate/components/DNSIssueCertificate.vue`
- `app/src/views/certificate/CertificateEditor.vue`
- `app/src/api/cert.ts`
- `api/certificate/self_signed.go`
- `api/certificate/self_signed_test.go`
---
### Task 1: Create the StringListInput component
**Files:**
- Create: `app/src/components/StringListInput/StringListInput.vue`
- Create: `app/src/components/StringListInput/index.ts`
- [ ] **Step 1: Create the component file**
Write `app/src/components/StringListInput/StringListInput.vue` exactly:
```vue
<script setup lang="ts">
defineProps<{
placeholder?: string
addButtonText?: string
}>()
const items = defineModel<string[]>({ required: true })
function addItem() {
items.value = [...items.value, '']
}
function removeItem(index: number) {
if (items.value.length <= 1)
return
const next = [...items.value]
next.splice(index, 1)
items.value = next
}
function updateItem(index: number, value: string) {
const next = [...items.value]
next[index] = value
items.value = next
}
</script>
<template>
<div class="space-y-2">
<div
v-for="(item, index) in items"
:key="index"
class="flex items-center gap-2"
>
<AInput
:value="item"
:placeholder="placeholder"
class="flex-1"
@update:value="(value: string) => updateItem(index, value)"
/>
<AButton
v-if="items.length > 1"
type="link"
danger
@click="removeItem(index)"
>
{{ $gettext('Remove') }}
</AButton>
</div>
<AButton
block
@click="addItem"
>
{{ addButtonText ?? $gettext('Add Item') }}
</AButton>
</div>
</template>
```
Notes:
- Uses `defineModel<string[]>({ required: true })` — Vue 3.4+ idiom, already used elsewhere in this project.
- `updateItem` swaps via spread to keep the reactive identity stable (mirrors how the original Custom Domains template mutates `customDomains[index]` via two-way binding; we go through an explicit setter so the model emits cleanly).
- [ ] **Step 2: Create the re-export**
Write `app/src/components/StringListInput/index.ts`:
```ts
import StringListInput from './StringListInput.vue'
export default StringListInput
```
- [ ] **Step 3: Lint and typecheck**
```bash
cd app && pnpm lint && pnpm typecheck
```
Expected: both exit 0. If lint flags ordering, run `pnpm lint:fix` then re-check.
- [ ] **Step 4: Commit**
```bash
git add app/src/components/StringListInput/
git commit -m "$(cat <<'EOF'
feat(ui): add StringListInput component
Reusable multi-row text input with Add/Remove buttons. Used in the
upcoming refactor of Custom Domains and self-signed Domains / IP
Addresses editors so all three share a single editor pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: Make Name required on the backend and add a covering Go test
Doing the backend first means the frontend changes in later tasks are validated against the new server contract end-to-end.
**Files:**
- Modify: `api/certificate/self_signed.go`
- Modify: `api/certificate/self_signed_test.go`
- [ ] **Step 1: Add `binding:"required"` to Name**
In `api/certificate/self_signed.go`, change the struct definition:
```go
type SelfSignedCertRequest struct {
Name string `json:"name"`
```
to:
```go
type SelfSignedCertRequest struct {
Name string `json:"name" binding:"required"`
```
(Only the `Name` field's tag changes; the other fields stay as-is.)
- [ ] **Step 2: Add the failing test first**
Append a new test to `api/certificate/self_signed_test.go`. Use the same pattern as the existing rollback test (which sets up `setupSelfSignedAPITest`, creates a gin router, marshals a `SelfSignedCertRequest`, POSTs via `httptest`).
```go
func TestGenerateSelfSignedCertRejectsEmptyName(t *testing.T) {
setupSelfSignedAPITest(t)
router := gin.New()
router.POST("/self_signed_cert", GenerateSelfSignedCert)
body, err := json.Marshal(SelfSignedCertRequest{
Domains: []string{"named.example"},
KeyType: string(certcrypto.EC256),
ValidityDays: 30,
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/self_signed_cert", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code < 400 || rec.Code >= 500 {
t.Fatalf("status = %d, want a 4xx for missing name", rec.Code)
}
if !strings.Contains(strings.ToLower(rec.Body.String()), "name") {
t.Fatalf("response body %q did not mention the missing name field", rec.Body.String())
}
}
```
Check existing imports in the test file. If `bytes`, `http`, `httptest`, `strings` are not already imported, add them. The rollback test already imports `bytes`, `encoding/json`, `net/http`, `net/http/httptest`, and `gin`, so most should be present — only `strings` may need adding.
- [ ] **Step 3: Run the new test to confirm it passes**
```bash
go test ./api/certificate/ -run TestGenerateSelfSignedCertRejectsEmptyName -race
```
Expected: PASS.
- [ ] **Step 4: Run the full self-signed test set to confirm no regression**
```bash
go test ./api/certificate/ ./internal/cert/ -race
```
Expected: all PASS. The existing `TestGenerateSelfSignedCertRollsBackDBOnFileWriteFailure` already sends `Name: "rollback-test"` so it's unaffected; `buildSelfSignedOptions` unit tests don't go through binding validation.
- [ ] **Step 5: Lint formatting**
```bash
gofmt -w api/certificate/self_signed.go api/certificate/self_signed_test.go
goimports -w api/certificate/self_signed.go api/certificate/self_signed_test.go 2>/dev/null || true
```
(If `goimports` isn't installed locally that's fine; `gofmt` is the hard gate.)
- [ ] **Step 6: Commit**
```bash
git add api/certificate/self_signed.go api/certificate/self_signed_test.go
git commit -m "$(cat <<'EOF'
feat(cert): require Name when generating self-signed certificates
Adds binding:"required" to SelfSignedCertRequest.Name so an empty name
is rejected at the request boundary, and covers the contract with a
new API-level test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 3: Refactor SelfSignedCertFields to use StringListInput, add renewal hint, mark Name required
**Files:**
- Modify: `app/src/views/certificate/components/SelfSignedCertFields.vue`
- [ ] **Step 1: Replace the component file**
Replace the entire contents of `app/src/views/certificate/components/SelfSignedCertFields.vue` with:
```vue
<script setup lang="ts">
import type { SelfSignedCertPayload } from '@/api/cert'
import NodeSelector from '@/components/NodeSelector'
import StringListInput from '@/components/StringListInput'
import { PrivateKeyTypeList } from '@/constants'
const props = defineProps<{
isKeyTypeReadonly?: boolean
hideRenewalNote?: boolean
}>()
const data = defineModel<SelfSignedCertPayload>({ required: true })
</script>
<template>
<AForm layout="vertical">
<AAlert
v-if="!props.hideRenewalNote"
class="mb-4"
type="info"
show-icon
:message="$gettext('Nginx UI will automatically renew this certificate as it approaches expiration, based on the global certificate renewal interval and this certificate\'s validity period.')"
/>
<AFormItem
:label="$gettext('Name')"
required
>
<AInput
v-model:value="data.name"
:placeholder="$gettext('Enter certificate name')"
/>
</AFormItem>
<AFormItem :label="$gettext('Domains')">
<StringListInput
v-model="data.domains"
:placeholder="$gettext('Enter domain name')"
:add-button-text="$gettext('Add Domain')"
/>
</AFormItem>
<AFormItem :label="$gettext('IP Addresses')">
<StringListInput
v-model="data.ip_addresses"
:placeholder="$gettext('Enter IP address')"
:add-button-text="$gettext('Add IP Address')"
/>
</AFormItem>
<AFormItem :label="$gettext('Key Type')">
<ASelect
v-model:value="data.key_type"
:disabled="props.isKeyTypeReadonly"
>
<ASelectOption
v-for="t in PrivateKeyTypeList"
:key="t.key"
:value="t.key"
>
{{ t.name }}
</ASelectOption>
</ASelect>
</AFormItem>
<AFormItem :label="$gettext('Valid For (days)')">
<AInputNumber
v-model:value="data.validity_days"
:min="1"
:max="3650"
class="w-full"
/>
<template #help>
{{ $gettext('Some browsers reject TLS certificates valid for more than 398 days.') }}
</template>
</AFormItem>
<AFormItem :label="$gettext('Sync to')">
<NodeSelector
v-model:target="data.sync_node_ids"
hidden-local
/>
</AFormItem>
</AForm>
</template>
```
Substantive changes vs. the original file:
- Added `StringListInput` import.
- Added `hideRenewalNote?: boolean` prop alongside `isKeyTypeReadonly`.
- Removed the previous Name placeholder text `Optional` and added the `required` attribute on the `<AFormItem>` for the asterisk.
- Replaced the Domains and IP Addresses `<ASelect mode="tags">` blocks with `StringListInput`.
- Added the renewal `AAlert` immediately under `<AForm>`, gated by `!hideRenewalNote`.
- [ ] **Step 2: Lint and typecheck**
```bash
cd app && pnpm lint && pnpm typecheck
```
Expected: both 0. `lint:fix` if needed.
- [ ] **Step 3: Commit**
```bash
git add app/src/views/certificate/components/SelfSignedCertFields.vue
git commit -m "$(cat <<'EOF'
feat(cert): unify self-signed editor and surface renewal hint
Switch Domains and IP Addresses to the shared StringListInput so all
self-signed field editors match the Custom Domains pattern. Add an
auto-renewal hint (suppressible via hideRenewalNote) and mark Name as
required to match the new backend contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 4: Update SelfSignedCertManagement to suppress the duplicate alert
**Files:**
- Modify: `app/src/views/certificate/components/SelfSignedCertManagement.vue`
This view already shows its own `type="success"` "managed by Nginx UI and renewed automatically" alert. We don't want both.
- [ ] **Step 1: Add the `hide-renewal-note` attribute**
In `app/src/views/certificate/components/SelfSignedCertManagement.vue`, find the `<SelfSignedCertFields>` element. Replace:
```vue
<SelfSignedCertFields
v-model="data"
is-key-type-readonly
/>
```
with:
```vue
<SelfSignedCertFields
v-model="data"
is-key-type-readonly
hide-renewal-note
/>
```
- [ ] **Step 2: Lint and typecheck**
```bash
cd app && pnpm lint && pnpm typecheck
```
Expected: both 0.
- [ ] **Step 3: Commit**
```bash
git add app/src/views/certificate/components/SelfSignedCertManagement.vue
git commit -m "$(cat <<'EOF'
chore(cert): suppress duplicate renewal alert in cert editor
SelfSignedCertManagement already has its own renewal-status alert;
pass hide-renewal-note to SelfSignedCertFields to avoid showing two
adjacent alerts saying the same thing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 5: Seed and filter payloads + validate Name in the three submit/save paths
**Files:**
- Modify: `app/src/api/cert.ts`
- Modify: `app/src/views/certificate/components/SelfSignedCertForm.vue`
- Modify: `app/src/views/certificate/components/DNSIssueCertificate.vue`
- Modify: `app/src/views/certificate/CertificateEditor.vue`
The new `StringListInput` keeps an empty placeholder row in the model array. Each writing path needs to (a) seed `['']` for empty arrays so the editor renders an empty row, and (b) trim + filter + validate Name before sending.
- [ ] **Step 1: Update `toSelfSignedPayload` in `app/src/api/cert.ts`**
Find:
```ts
export function toSelfSignedPayload(c: Cert): SelfSignedCertPayload {
return {
name: c.name ?? '',
domains: [...(c.domains ?? [])],
ip_addresses: [...(c.self_signed_config?.ip_addresses ?? [])],
key_type: c.key_type || PrivateKeyTypeEnum.P256,
validity_days: c.self_signed_config?.validity_days || 365,
sync_node_ids: [...(c.sync_node_ids ?? [])],
}
}
```
Replace with:
```ts
export function toSelfSignedPayload(c: Cert): SelfSignedCertPayload {
const domains = c.domains?.length ? [...c.domains] : ['']
const ipAddresses = c.self_signed_config?.ip_addresses?.length
? [...c.self_signed_config.ip_addresses]
: ['']
return {
name: c.name ?? '',
domains,
ip_addresses: ipAddresses,
key_type: c.key_type || PrivateKeyTypeEnum.P256,
validity_days: c.self_signed_config?.validity_days || 365,
sync_node_ids: [...(c.sync_node_ids ?? [])],
}
}
```
- [ ] **Step 2: Update `SelfSignedCertForm.vue`**
Find `emptyForm()`:
```ts
function emptyForm(): SelfSignedCertPayload {
return {
name: '',
domains: [...(props.defaultDomains ?? [])],
ip_addresses: [],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
```
Replace with:
```ts
function emptyForm(): SelfSignedCertPayload {
const defaultDomains = props.defaultDomains ?? []
return {
name: '',
domains: defaultDomains.length ? [...defaultDomains] : [''],
ip_addresses: [''],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
```
Find `submit()`:
```ts
async function submit() {
if (form.value.domains.length === 0 && form.value.ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
loading.value = true
try {
const created = await cert.generate_self_signed(form.value)
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('created', created)
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
loading.value = false
}
}
```
Replace with:
```ts
async function submit() {
const name = (form.value.name ?? '').trim()
const domains = form.value.domains.map(d => d.trim()).filter(Boolean)
const ip_addresses = form.value.ip_addresses.map(s => s.trim()).filter(Boolean)
if (!name) {
message.error($gettext('Please enter a name for the certificate'))
return
}
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
loading.value = true
try {
const created = await cert.generate_self_signed({
...form.value,
name,
domains,
ip_addresses,
})
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('created', created)
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
loading.value = false
}
}
```
- [ ] **Step 3: Update `DNSIssueCertificate.vue`**
Find `emptySelfSignedPayload()`:
```ts
function emptySelfSignedPayload(): SelfSignedCertPayload {
return {
name: '',
domains: [],
ip_addresses: [],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
```
Replace with:
```ts
function emptySelfSignedPayload(): SelfSignedCertPayload {
return {
name: '',
domains: [''],
ip_addresses: [''],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
}
```
Find `submitSelfSigned()`:
```ts
async function submitSelfSigned() {
const { domains, ip_addresses } = selfSignedPayload.value
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
selfSignedLoading.value = true
try {
await cert.generate_self_signed(selfSignedPayload.value)
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('issued')
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
selfSignedLoading.value = false
}
}
```
Replace with:
```ts
async function submitSelfSigned() {
const name = (selfSignedPayload.value.name ?? '').trim()
const domains = selfSignedPayload.value.domains.map(d => d.trim()).filter(Boolean)
const ip_addresses = selfSignedPayload.value.ip_addresses.map(s => s.trim()).filter(Boolean)
if (!name) {
message.error($gettext('Please enter a name for the certificate'))
return
}
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
selfSignedLoading.value = true
try {
await cert.generate_self_signed({
...selfSignedPayload.value,
name,
domains,
ip_addresses,
})
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('issued')
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
selfSignedLoading.value = false
}
}
```
- [ ] **Step 4: Update `CertificateEditor.vue`**
Find the `save()` function. The current `isSelfSigned` branch (around lines 58-66) is:
```ts
async function save() {
try {
let savedId = data.value.id
if (isSelfSigned.value && selfSignedPayload.value && data.value.id) {
const currentId = data.value.id
const result = await cert.modify_self_signed(currentId, selfSignedPayload.value)
savedId = result.id || currentId
data.value = { ...result, id: savedId }
}
else {
await certStore.save()
savedId = data.value.id
}
```
Modify the `isSelfSigned` branch so it trims and validates before calling the API. Replace lines from `if (isSelfSigned.value && ...)` through the closing `}` of that branch with:
```ts
if (isSelfSigned.value && selfSignedPayload.value && data.value.id) {
const payload = selfSignedPayload.value
const name = (payload.name ?? '').trim()
const domains = payload.domains.map(d => d.trim()).filter(Boolean)
const ip_addresses = payload.ip_addresses.map(s => s.trim()).filter(Boolean)
if (!name) {
message.error($gettext('Please enter a name for the certificate'))
return
}
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
const currentId = data.value.id
const result = await cert.modify_self_signed(currentId, {
...payload,
name,
domains,
ip_addresses,
})
savedId = result.id || currentId
data.value = { ...result, id: savedId }
}
```
(Leave the `else` branch and the rest of `save()` exactly as it is.)
- [ ] **Step 5: Lint and typecheck**
```bash
cd app && pnpm lint && pnpm typecheck
```
Expected: both 0.
- [ ] **Step 6: Commit**
```bash
git add app/src/api/cert.ts \
app/src/views/certificate/components/SelfSignedCertForm.vue \
app/src/views/certificate/components/DNSIssueCertificate.vue \
app/src/views/certificate/CertificateEditor.vue
git commit -m "$(cat <<'EOF'
feat(cert): seed and filter self-signed payloads, validate Name
StringListInput preserves empty placeholder rows for editing; seed
arrays with [''] in toSelfSignedPayload / emptySelfSignedPayload /
emptyForm so the editor always renders an empty row to type into.
Each submit/save path trims and filters the arrays before sending and
now rejects an empty Name client-side to match the new server contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 6: Switch Custom Domains in DNSIssueCertificate to StringListInput
This is a pure refactor — same UX, less duplication.
**Files:**
- Modify: `app/src/views/certificate/components/DNSIssueCertificate.vue`
- [ ] **Step 1: Add the import**
Inside the `<script setup>` block, add (next to the existing `SelfSignedCertFields` import):
```ts
import StringListInput from '@/components/StringListInput'
```
- [ ] **Step 2: Replace the inline Custom Domains list with StringListInput**
In the `<template>` block, find:
```vue
<template v-else-if="certType === 'custom'">
<AFormItem :label="$gettext('Custom Domains')">
<div class="space-y-2">
<div
v-for="(_, index) in customDomains"
:key="index"
class="flex items-center gap-2"
>
<AInput
v-model:value="customDomains[index]"
:placeholder="$gettext('Enter domain name')"
class="flex-1"
/>
<AButton
v-if="customDomains.length > 1"
type="link"
danger
@click="removeCustomDomain(index)"
>
{{ $gettext('Remove') }}
</AButton>
</div>
<AButton
block
@click="addCustomDomain"
>
{{ $gettext('Add Domain') }}
</AButton>
</div>
<AAlert
:message="$gettext('All selected subdomains must belong to the same DNS Provider, otherwise the certificate application will fail.')"
type="info"
show-icon
banner
class="mt-3"
/>
</AFormItem>
</template>
```
Replace with:
```vue
<template v-else-if="certType === 'custom'">
<AFormItem :label="$gettext('Custom Domains')">
<StringListInput
v-model="customDomains"
:placeholder="$gettext('Enter domain name')"
:add-button-text="$gettext('Add Domain')"
/>
<AAlert
:message="$gettext('All selected subdomains must belong to the same DNS Provider, otherwise the certificate application will fail.')"
type="info"
show-icon
banner
class="mt-3"
/>
</AFormItem>
</template>
```
- [ ] **Step 3: Remove the now-unused `addCustomDomain` and `removeCustomDomain` helpers**
In the `<script setup>` block, find and delete:
```ts
function addCustomDomain() {
customDomains.value.push('')
}
function removeCustomDomain(index: number) {
if (customDomains.value.length > 1) {
customDomains.value.splice(index, 1)
}
}
```
(Keep the `customDomains` ref and the `computedDomains` / `computedMainDomain` references — they're still used by `issueCert`.)
- [ ] **Step 4: Lint and typecheck**
```bash
cd app && pnpm lint && pnpm typecheck
```
Expected: both 0. `pnpm typecheck` should catch any orphaned references to `addCustomDomain` / `removeCustomDomain`.
- [ ] **Step 5: Commit**
```bash
git add app/src/views/certificate/components/DNSIssueCertificate.vue
git commit -m "$(cat <<'EOF'
refactor(cert): use StringListInput for Custom Domains
Drop the inline multi-row template + add/remove helpers in favour of
the shared StringListInput component, matching the editor used by the
self-signed branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 7: Final lint/test sweep + manual verification handoff
**Files:** none modified.
- [ ] **Step 1: Final frontend gate**
```bash
cd app && pnpm lint && pnpm typecheck
```
Expected: both 0.
- [ ] **Step 2: Final backend gate**
```bash
cd .. && go test ./api/certificate/... ./internal/cert/... -race
```
Expected: all PASS, including the new `TestGenerateSelfSignedCertRejectsEmptyName`.
- [ ] **Step 3: Manual smoke checklist for the user**
(Hand off to the human — they boot the app, you don't.)
Confirm:
1. Issue Certificate → Self-signed:
- Renewal alert visible at the top.
- Name field has a red asterisk; placeholder reads `Enter certificate name`.
- Domains row appears as a single empty `AInput` + Add Domain block button.
- IP Addresses row appears the same way with Add IP Address.
2. Generate with everything empty → toast: `Please enter a name for the certificate`.
3. Fill Name only → toast: `Please enter at least one domain or IP address`.
4. Fill Name + one domain → cert created, dialog closes, table refreshes, list shows the name.
5. Open an existing self-signed cert in the editor → SelfSignedCertManagement's own renewal alert remains; no second alert from SelfSignedCertFields.
6. Edit existing cert with empty Name → save blocked until Name is filled.
7. Site editor self-signed shortcut → renewal alert visible; Name required.
8. Custom Domains branch in Issue Certificate dialog → unchanged appearance and behaviour (visual regression check).
No commit produced by this task.
---
## Wrap-up checklist
- [ ] All 6 code commits land on `feature/self-signed-certificate`.
- [ ] `pnpm lint`, `pnpm typecheck`, and the Go test sweep are all green on the final tree.
- [ ] `git status` is clean.
- [ ] Manual smoke checklist done (or explicit user sign-off).
@@ -0,0 +1,183 @@
# Merge Self-signed Certificate into Issue Certificate Dialog
Date: 2026-05-23
Author: brainstorming session with Jacky
Status: approved
## Background
The certificate list page (`app/src/views/certificate/CertificateList/Certificate.vue`)
currently exposes three actions in the card header:
1. **Import** — navigates to `/certificates/import`
2. **Self-signed Certificate** — opens `SelfSignedCertForm.vue` modal
3. **Issue certificate** — opens `DNSIssueCertificate.vue` modal (ACME wildcard / custom domain)
The Self-signed and Issue flows produce certificates by different means but land in
the same list, and the two-button arrangement is redundant from the user's
perspective. We will consolidate them so that "Issue certificate" is the single
entry point for creating a new certificate, with ACME and self-signed exposed as
alternative *certificate types* inside that dialog.
## Goal
* Header collapses from three actions to two: **Import** and **Issue certificate**.
* The Issue Certificate dialog gains a `Self-signed` option in its existing
Certificate Type dropdown.
* Selecting `Self-signed` swaps the form body to the self-signed field set and
routes submission through the existing self-signed generation API.
* No backend changes.
## Non-goals
* No changes to the site-editor self-signed shortcut
(`app/src/views/site/site_edit/components/Cert/SelfSignedCert.vue`) — it
continues to use `SelfSignedCertForm.vue` directly with default domains.
* No changes to the editor view of an existing self-signed certificate
(`SelfSignedCertManagement.vue`).
* No rename of `DNSIssueCertificate.vue` (would only churn i18n `.pot` refs).
* No backend / API / Go test changes.
## Affected files
| File | Change |
| --- | --- |
| `app/src/views/certificate/components/DNSIssueCertificate.vue` | Extend `certType` with `'self_signed'`; render `SelfSignedCertFields` and call self-signed API when selected. |
| `app/src/views/certificate/CertificateList/Certificate.vue` | Remove the standalone Self-signed button and related imports / refs / handlers. |
| `app/src/views/certificate/components/SelfSignedCertForm.vue` | **Untouched.** Still used by the site editor. |
| `app/src/views/certificate/components/SelfSignedCertFields.vue` | **Untouched.** Reused as the field set inside the merged dialog. |
## Design
### DNSIssueCertificate.vue
State additions:
* `certType: Ref<'wildcard' | 'custom' | 'self_signed'>` — extend the existing
union with `'self_signed'`.
* `selfSignedPayload: Ref<SelfSignedCertPayload>` — mirror the shape used by
`SelfSignedCertForm.emptyForm()`:
```ts
{
name: '',
domains: [],
ip_addresses: [],
key_type: PrivateKeyTypeEnum.P256,
validity_days: 365,
sync_node_ids: [],
}
```
* `selfSignedLoading: Ref<boolean>` — disables the Generate button while the
POST is in flight.
`open()` resets `selfSignedPayload` alongside the existing resets.
Template:
* The Certificate Type select gets a third `<ASelectOption value="self_signed">`
labelled `Self-signed Certificate`.
* `v-if="certType === 'self_signed'"` branch renders
`<SelfSignedCertFields v-model="selfSignedPayload" />` only.
* Hides: wildcard domain input, custom-domains list, the
`AutoCertForm` block, the `ObtainCertLive` step.
* Footer button:
* `certType === 'self_signed'` → button label `Generate`, calls a new
`submitSelfSigned()`, shows `selfSignedLoading` spinner.
* Other modes → unchanged `Next` button calling `issueCert()`.
`submitSelfSigned()`:
```ts
async function submitSelfSigned() {
const { domains, ip_addresses } = selfSignedPayload.value
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
selfSignedLoading.value = true
try {
await cert.generate_self_signed(selfSignedPayload.value)
message.success($gettext('Self-signed certificate generated'))
visible.value = false
emit('issued')
}
// eslint-disable-next-line ts/no-explicit-any
catch (e: any) {
message.error(e.message ?? $gettext('Failed to generate self-signed certificate'))
}
finally {
selfSignedLoading.value = false
}
}
```
(`message` already exists via `App.useApp()` in the component; the wording
matches the current `SelfSignedCertForm` exactly so translations can be reused.)
Behavioral notes:
* Switching `certType` does not wipe other branches' state — user can switch
back and forth without losing input.
* `step` only advances on the ACME path; the self-signed path stays at the
form view and closes on success.
### Certificate.vue
Remove:
* The `SafetyOutlined` import.
* The `SelfSignedCertForm` import.
* `import type { Cert } from '@/api/cert'` (only used by `onSelfSignedCreated`).
* The `refSelfSigned` ref.
* The `onSelfSignedCreated` function.
* The `<AButton>` block rendering the Self-signed Certificate action.
* The `<SelfSignedCertForm ref="refSelfSigned" @created="onSelfSignedCreated" />`
block at the bottom of the template.
Result: header has only Import + Issue certificate; `<WildcardCertificate>`
(name preserved, semantically now "Issue Certificate") remains the single
modal.
### Behavioural change
After a self-signed certificate is generated through the merged dialog:
* **Before:** `router.push('/certificates/:id')` from `onSelfSignedCreated`.
* **After:** dialog closes + table refreshes via the existing `issued` emit,
matching the ACME completion behaviour.
Rationale: the merged dialog has one exit contract. Users can click the new row
to open the editor; this is consistent with ACME flow and avoids a context jump
that ACME users don't experience.
## Testing
* Frontend gates: `pnpm lint`, `pnpm lint:fix`, `pnpm typecheck` must all pass.
* Manual verification:
1. Certificate list header shows only Import + Issue certificate.
2. Issue Certificate dialog defaults to Wildcard; ACME flow still works end-to-end.
3. Switching to Custom Domains still works (no regression).
4. Switching to Self-signed shows the self-signed field set; Generate creates
a row and closes the dialog with the table refreshed.
5. Validation: clicking Generate with no domain and no IP shows the existing
"Please enter at least one domain or IP address" error.
6. Site editor → SelfSignedCert.vue still opens its own modal and works
unchanged (regression check on the untouched code path).
* No backend changes → existing Go tests remain authoritative; no new Go tests
required for this UI refactor.
## i18n
* Existing key `Self-signed Certificate` is already in the catalog (current
button label) — reuse for the dropdown option label.
* `Generate`, `Please enter at least one domain or IP address`,
`Self-signed certificate generated`,
`Failed to generate self-signed certificate` already exist in
`SelfSignedCertForm.vue`.
* No new strings required.
## Rollout
* Single PR against `dev`.
* Reviewer touchpoints: certificate list header, Issue Certificate dialog,
regression check on site editor self-signed path.
@@ -0,0 +1,232 @@
# Self-signed Certificate UX Enhancements
Date: 2026-05-23
Author: brainstorming session with Jacky
Status: approved
Builds on: [2026-05-23-merge-self-signed-into-issue-cert-design.md](./2026-05-23-merge-self-signed-into-issue-cert-design.md)
## Background
After the initial merge of the self-signed creation flow into the Issue
Certificate dialog, three follow-up improvements emerged from manual testing:
1. **Inconsistent editors.** The Custom Domains branch uses a multi-row
`AInput` list with explicit Add/Remove buttons, while the self-signed
Domains and IP Addresses use a chip-style tags select. Same dialog, two
editing models.
2. **Missing auto-renewal context.** Users creating a self-signed cert have
no in-form indication that Nginx UI will renew it automatically; the
policy depends on a global setting and the per-cert validity period.
3. **Empty Name slips through.** The backend silently accepts an empty
`Name`, which leaves the certificate list with blank rows and forces the
file-system slug to fall back to the first domain or IP. For self-signed
certificates we want Name to be required.
## Goals
* Single, reusable list-input component used by Custom Domains and self-signed
Domains/IPs.
* Inline policy hint inside the self-signed form so users understand renewal
expectations at creation time.
* Self-signed certificates require a non-empty Name (enforced on both client
and server).
## Non-goals
* No client-side IP format validation; the backend already enforces it via
`binding:"omitempty,dive,ip"` and surfaces the error in the toast.
* No DB migration of existing self-signed rows with empty names. They stay
as-is until the user opens and re-saves them (at which point the new
required validation kicks in).
* No rename of `DNSIssueCertificate.vue`; same scope discipline as before.
* No change to ACME (Wildcard / Custom Domains) submit semantics.
## Affected files
New:
| File | Responsibility |
| --- | --- |
| `app/src/components/StringListInput/StringListInput.vue` | Reusable multi-row string-array input with Add / Remove. |
| `app/src/components/StringListInput/index.ts` | Re-export. |
Modified:
| File | Change |
| --- | --- |
| `app/src/views/certificate/components/DNSIssueCertificate.vue` | Custom Domains uses `StringListInput`; submitSelfSigned trims/filters and validates Name; seeds payload with `domains: ['']`, `ip_addresses: ['']`. |
| `app/src/views/certificate/components/SelfSignedCertFields.vue` | Domains/IPs use `StringListInput`; adds renewal-policy `AAlert` (with `hideRenewalNote?: boolean` prop to suppress); Name becomes required field. |
| `app/src/views/certificate/components/SelfSignedCertForm.vue` | `emptyForm()` seeds empty-row arrays; `submit()` trims/filters and validates Name. |
| `app/src/views/certificate/components/SelfSignedCertManagement.vue` | Passes `hide-renewal-note` to `SelfSignedCertFields` to avoid double alert. |
| `app/src/views/certificate/CertificateEditor.vue` | `save()` trims/filters arrays and validates Name before `modify_self_signed`. |
| `app/src/api/cert.ts` | `toSelfSignedPayload()` seeds empty-row arrays. |
| `api/certificate/self_signed.go` | `SelfSignedCertRequest.Name` gets `binding:"required"`. |
| `api/certificate/self_signed_test.go` | Add test that POST `/self_signed_cert` with empty Name returns 400. |
Untouched:
| File | Reason |
| --- | --- |
| `app/src/views/site/site_edit/components/Cert/SelfSignedCert.vue` | Calls `SelfSignedCertForm` with `defaultDomains`; downstream changes propagate automatically. |
| `internal/cert/self_signed.go` and other backend helpers | Validation lives at the request boundary. |
## Component: `StringListInput`
API:
```ts
interface Props {
placeholder?: string
addButtonText?: string // default: $gettext('Add Item')
// No validator prop in v1 — YAGNI.
}
```
`v-model: string[]` (required). The model array may legitimately contain a
single empty string while the user is composing the first value. Consumers
are responsible for filtering empties on submit.
Behaviour:
* Renders one `AInput` per array entry.
* `Remove` link (red) shown when array length > 1; clicking it splices that
index out.
* Block `AButton` at the bottom labelled by `addButtonText` (default `Add Item`),
pushes `''` onto the array.
* No internal validation; uses `:placeholder` for hint text.
This is literally the pattern from the existing `Custom Domains` branch in
`DNSIssueCertificate.vue` — moved into its own file and parameterized.
## SelfSignedCertFields changes
* Replace the Domains `<ASelect mode="tags">` with `<StringListInput v-model="data.domains" :placeholder="$gettext('Enter domain name')" :add-button-text="$gettext('Add Domain')" />`.
* Replace the IP Addresses `<ASelect mode="tags">` with `<StringListInput v-model="data.ip_addresses" :placeholder="$gettext('Enter IP address')" :add-button-text="$gettext('Add IP Address')" />`.
* Name field:
* Update wrapping `<AFormItem>` to set `required` for the asterisk.
* Change placeholder from `Optional` to `Enter certificate name`.
* Add new prop `hideRenewalNote?: boolean` (defaults `false`).
* When `!hideRenewalNote`, render at the top of the form:
```vue
<AAlert
class="mb-4"
type="info"
show-icon
:message="$gettext('Nginx UI will automatically renew this certificate as it approaches expiration, based on the global certificate renewal interval and this certificate\'s validity period.')"
/>
```
## Payload seeding & filtering
To present an empty editable row, payload factories seed with `['']`:
* `emptySelfSignedPayload()` in `DNSIssueCertificate.vue``domains: ['']`, `ip_addresses: ['']`
* `emptyForm()` in `SelfSignedCertForm.vue``domains: defaultDomains?.length ? [...defaultDomains] : ['']`, `ip_addresses: ['']`
* `toSelfSignedPayload(c)` in `cert.ts``domains: c.domains?.length ? [...c.domains] : ['']`, `ip_addresses: c.self_signed_config?.ip_addresses?.length ? [...c.self_signed_config.ip_addresses] : ['']`
Each submit/save path trims and filters before validation:
```ts
const name = (payload.name ?? '').trim()
const domains = payload.domains.map(d => d.trim()).filter(Boolean)
const ip_addresses = payload.ip_addresses.map(s => s.trim()).filter(Boolean)
if (!name) {
message.error($gettext('Please enter a name for the certificate'))
return
}
if (domains.length === 0 && ip_addresses.length === 0) {
message.error($gettext('Please enter at least one domain or IP address'))
return
}
await cert.generate_self_signed({ ...payload, name, domains, ip_addresses })
```
The same shape is used in:
* `DNSIssueCertificate.submitSelfSigned()`
* `SelfSignedCertForm.submit()`
* `CertificateEditor.save()` (in the `isSelfSigned` branch)
## Backend: required Name
Update `SelfSignedCertRequest` in `api/certificate/self_signed.go`:
```go
type SelfSignedCertRequest struct {
Name string `json:"name" binding:"required"`
Domains []string `json:"domains" binding:"omitempty"`
IPAddresses []string `json:"ip_addresses" binding:"omitempty,dive,ip"`
KeyType string `json:"key_type" binding:"omitempty,auto_cert_key_type"`
ValidityDays int `json:"validity_days" binding:"omitempty,min=1,max=3650"`
SyncNodeIds []uint64 `json:"sync_node_ids" binding:"omitempty"`
}
```
This applies to both `GenerateSelfSignedCert` and `ModifySelfSignedCert`,
since both bind the same struct via `cosy.BindAndValid`.
The CommonName fallback in `selfSignedSlug` (line ~69) stays as defensive code
even though it's now unreachable for fresh requests — cheap, and protects
direct callers of `selfSignedSlug` outside the handler.
## Testing
### Frontend
* `pnpm lint` / `pnpm lint:fix` / `pnpm typecheck` from `app/` — must pass.
* No new component tests added (project does not carry component tests for
this view; manual smoke covers behaviour).
### Backend
* Existing self-signed Go tests must remain green: `go test ./api/certificate/... ./internal/cert/...`.
* Add `TestGenerateSelfSignedCertRejectsEmptyName` in
`api/certificate/self_signed_test.go`:
* POST `/self_signed_cert` with `{"domains":["a.test"],"validity_days":30}`
(no `name`) → expect HTTP 4xx and an error payload referencing the
`name` field. Use the same `setupSelfSignedAPITest` helper as the rollback
test.
* No DB migration tests required — existing rows are not touched.
### Manual smoke
1. Open Issue Certificate → Self-signed:
* Renewal alert visible at the top.
* Name field shows red asterisk; placeholder `Enter certificate name`.
* Domains and IPs each render as multi-row inputs with `Add Domain` / `Add IP Address`.
2. Click `Generate` with everything empty → `Please enter a name for the certificate`.
3. Fill Name only → `Please enter at least one domain or IP address`.
4. Fill Name + one domain → cert is created; list refreshes; row has the name.
5. Edit an existing self-signed cert that has a Name → save still works.
6. Edit an existing self-signed cert with empty Name (if any exist) → save is
blocked until Name is filled.
7. Site editor → Self-signed shortcut: alert visible (same field component);
Name required.
8. Editor for an existing self-signed cert → `SelfSignedCertManagement`'s own
"managed by Nginx UI and renewed automatically" alert remains; the new
policy alert is NOT shown (suppressed by `hide-renewal-note`).
9. Custom Domains branch in Issue Certificate dialog still renders identically
to before (visual regression check).
## i18n
New strings (English source):
* `Add Item`
* `Add Domain` (existing)
* `Add IP Address`
* `Enter domain name` (existing)
* `Enter IP address`
* `Enter certificate name`
* `Please enter a name for the certificate`
* `Nginx UI will automatically renew this certificate as it approaches expiration, based on the global certificate renewal interval and this certificate's validity period.`
The strings already present in the original `SelfSignedCertForm` continue to
be reused. `messages.pot` regeneration is a separate operational follow-up
(noted in the prior PR's review) and is out of scope for this PR.
## Rollout
* Single PR on top of the two already-landed commits on `feature/self-signed-certificate`.
* Reviewer touchpoints: new component, three consumer wirings, backend
binding change, Go test addition.
+4
View File
@@ -237,5 +237,9 @@ func getAutoRenewTargetName(certModel *model.Cert) string {
return certModel.Name
}
if certModel.SelfSignedConfig != nil && len(certModel.SelfSignedConfig.IPAddresses) > 0 {
return strings.Join(certModel.SelfSignedConfig.IPAddresses, ", ")
}
return "unknown certificate"
}
+132
View File
@@ -0,0 +1,132 @@
package cert
import (
"runtime"
"time"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/0xJacky/Nginx-UI/internal/notification"
"github.com/0xJacky/Nginx-UI/model"
"github.com/0xJacky/Nginx-UI/settings"
pkgerrors "github.com/pkg/errors"
"github.com/uozi-tech/cosy/logger"
)
// RenewSelfSignedCerts renews every self-signed certificate that is close to
// expiry. It is invoked by a dedicated cron job.
func RenewSelfSignedCerts() {
defer func() {
if err := recover(); err != nil {
buf := make([]byte, 1024)
runtime.Stack(buf, false)
logger.Errorf("%s\n%s", err, buf)
}
}()
logger.Info("RenewSelfSignedCerts Worker Started")
db := model.UseDB()
if db == nil {
return
}
var certs []*model.Cert
db.Where("auto_cert = ?", model.AutoCertSelfSigned).Find(&certs)
now := time.Now()
renewalInterval := settings.CertSettings.GetCertRenewalInterval()
for _, certModel := range certs {
renewSelfSignedCert(certModel, now, renewalInterval)
}
logger.Info("RenewSelfSignedCerts Worker End")
}
// renewSelfSignedCert renews a single self-signed certificate when it is due.
func renewSelfSignedCert(certModel *model.Cert, now time.Time, renewalInterval int) {
targetName := getAutoRenewTargetName(certModel)
if shouldSkipAutoRenew(certModel, now) {
logger.Infof("Skip auto renew for %s until %s after previous failure", targetName,
certModel.LastAutoRenewAt.Add(autoRenewFailureRetryCooldown).Format(time.DateTime))
return
}
due, err := selfSignedRenewalDue(certModel, now, renewalInterval)
if err == nil && !due {
return
}
// A Logger is allocated only once renewal is actually attempted or has
// failed; non-due certificates skip it to avoid a per-tick goroutine and
// an empty-log database write.
log := NewLogger()
log.SetCertModel(certModel)
defer log.Close()
if err != nil {
handleAutoRenewFailure(certModel, log, targetName, err)
return
}
certPEM, keyPEM, err := RegenerateSelfSigned(certModel)
if err != nil {
handleAutoRenewFailure(certModel, log, targetName, err)
return
}
content := &Content{
SSLCertificatePath: certModel.SSLCertificatePath,
SSLCertificateKeyPath: certModel.SSLCertificateKeyPath,
SSLCertificate: string(certPEM),
SSLCertificateKey: string(keyPEM),
}
if err = content.WriteFile(); err != nil {
handleAutoRenewFailure(certModel, log, targetName, err)
return
}
nginx.Reload()
updateAutoRenewStatus(certModel, now, "")
notification.Success("Renew Certificate Success",
"Certificate %{name} renewed successfully", map[string]any{"name": targetName})
if err = SyncToRemoteServer(certModel); err != nil {
notification.Error("Sync Certificate Error", err.Error(), nil)
}
}
// selfSignedRenewalDue reports whether a self-signed certificate is due for
// renewal. It returns an error when the certificate cannot be inspected.
func selfSignedRenewalDue(certModel *model.Cert, now time.Time, renewalInterval int) (bool, error) {
if certModel.SelfSignedConfig == nil {
return false, pkgerrors.New("self-signed certificate config is empty")
}
if certModel.SSLCertificatePath == "" {
return false, pkgerrors.New("ssl certificate path is empty for self-signed certificate")
}
info, err := GetCertInfo(certModel.SSLCertificatePath)
if err != nil {
return false, pkgerrors.Wrap(err, "get self-signed certificate info error")
}
return shouldRenewSelfSignedCert(info, now, renewalInterval), nil
}
// shouldRenewSelfSignedCert reports whether a self-signed certificate with the
// given info should be renewed now. It mirrors the renewal-threshold logic of
// the ACME auto-renewal job.
func shouldRenewSelfSignedCert(info *Info, now time.Time, renewalInterval int) bool {
certAge := int(now.Sub(info.NotBefore).Hours() / 24)
daysUntilExpiration := int(info.NotAfter.Sub(now).Hours() / 24)
totalValidityDays := int(info.NotAfter.Sub(info.NotBefore).Hours() / 24)
if totalValidityDays < renewalInterval {
// short-lived certificate: renew once 2/3 of the lifetime has elapsed
earlyRenewalThreshold := 2 * totalValidityDays / 3
return daysUntilExpiration <= earlyRenewalThreshold
}
// normal certificate: renew once the age reaches the renewal interval
return certAge >= renewalInterval
}
@@ -0,0 +1,69 @@
package cert
import (
"testing"
"time"
"github.com/0xJacky/Nginx-UI/model"
)
func TestShouldRenewSelfSignedCert(t *testing.T) {
now := time.Date(2026, time.May, 18, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
notBefore time.Time
notAfter time.Time
renewalInterval int
expected bool
}{
{
name: "normal cert young enough is not renewed",
notBefore: now.AddDate(0, 0, -3),
notAfter: now.AddDate(0, 0, 362),
renewalInterval: 7,
expected: false,
},
{
name: "normal cert past renewal interval is renewed",
notBefore: now.AddDate(0, 0, -10),
notAfter: now.AddDate(0, 0, 355),
renewalInterval: 7,
expected: true,
},
{
name: "short-lived cert with plenty of life left is not renewed",
notBefore: now.AddDate(0, 0, -1),
notAfter: now.AddDate(0, 0, 4),
renewalInterval: 7,
expected: false,
},
{
name: "short-lived cert near expiry is renewed",
notBefore: now.AddDate(0, 0, -4),
notAfter: now.AddDate(0, 0, 1),
renewalInterval: 7,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &Info{NotBefore: tt.notBefore, NotAfter: tt.notAfter}
if got := shouldRenewSelfSignedCert(info, now, tt.renewalInterval); got != tt.expected {
t.Fatalf("shouldRenewSelfSignedCert() = %v, want %v", got, tt.expected)
}
})
}
}
func TestSelfSignedRenewalDueRejectsMissingConfig(t *testing.T) {
_, err := selfSignedRenewalDue(&model.Cert{
AutoCert: model.AutoCertSelfSigned,
SelfSignedConfig: nil,
SSLCertificatePath: "unused.pem",
}, time.Now(), 7)
if err == nil {
t.Fatalf("expected missing self-signed config to fail renewal")
}
}
+5
View File
@@ -28,4 +28,9 @@ var (
ErrObtainCert = e.New(50022, "obtain cert error: {0}")
ErrRevokeCert = e.New(50023, "revoke cert error: {0}")
ErrNoCertificateAvailable = e.New(50031, "no certificate available")
ErrSelfSignedGenerateKey = e.New(50032, "generate self-signed private key error: {0}")
ErrSelfSignedCreateCert = e.New(50033, "create self-signed certificate error: {0}")
ErrSelfSignedNoSAN = e.New(50034, "at least one domain or IP address is required")
ErrSelfSignedInvalidIP = e.New(50035, "invalid IP address: {0}")
ErrCertIsNotSelfSigned = e.New(50036, "certificate is not a self-signed certificate")
)
+183
View File
@@ -0,0 +1,183 @@
package cert
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"time"
"github.com/0xJacky/Nginx-UI/internal/helper"
"github.com/0xJacky/Nginx-UI/model"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/uozi-tech/cosy"
"github.com/uozi-tech/cosy/logger"
)
const (
// SelfSignedDefaultValidityDays is used when no validity period is given.
SelfSignedDefaultValidityDays = 365
// SelfSignedMaxValidityDays caps the validity period.
SelfSignedMaxValidityDays = 3650
// selfSignedClockSkewBackdate backdates NotBefore to tolerate clock skew.
selfSignedClockSkewBackdate = 5 * time.Minute
)
// SelfSignedOptions describes the parameters for generating a self-signed
// leaf certificate.
type SelfSignedOptions struct {
CommonName string
DNSNames []string
IPAddresses []string
KeyType certcrypto.KeyType
ValidityDays int
}
// GenerateSelfSigned builds a self-signed leaf certificate and returns the
// PEM-encoded certificate and private key.
func GenerateSelfSigned(opts SelfSignedOptions) (certPEM, keyPEM []byte, err error) {
signer, err := certcrypto.GeneratePrivateKey(helper.GetKeyType(opts.KeyType))
if err != nil {
return nil, nil, cosy.WrapErrorWithParams(ErrSelfSignedGenerateKey, err.Error())
}
return signSelfSigned(opts, signer)
}
// signSelfSigned creates a self-signed certificate from the given options and
// signer, returning the PEM-encoded certificate and private key.
func signSelfSigned(opts SelfSignedOptions, signer crypto.Signer) (certPEM, keyPEM []byte, err error) {
ipAddresses := make([]net.IP, 0, len(opts.IPAddresses))
for _, raw := range opts.IPAddresses {
ip := net.ParseIP(raw)
if ip == nil {
return nil, nil, cosy.WrapErrorWithParams(ErrSelfSignedInvalidIP, raw)
}
ipAddresses = append(ipAddresses, ip)
}
if len(opts.DNSNames) == 0 && len(ipAddresses) == 0 {
return nil, nil, ErrSelfSignedNoSAN
}
validityDays := opts.ValidityDays
if validityDays <= 0 {
validityDays = SelfSignedDefaultValidityDays
}
if validityDays > SelfSignedMaxValidityDays {
validityDays = SelfSignedMaxValidityDays
}
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, cosy.WrapErrorWithParams(ErrSelfSignedCreateCert, err.Error())
}
now := time.Now()
// ECDSA keys do not perform key encipherment; only RSA keys get that bit.
keyUsage := x509.KeyUsageDigitalSignature
if _, isRSA := signer.Public().(*rsa.PublicKey); isRSA {
keyUsage |= x509.KeyUsageKeyEncipherment
}
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{CommonName: opts.CommonName},
DNSNames: opts.DNSNames,
IPAddresses: ipAddresses,
NotBefore: now.Add(-selfSignedClockSkewBackdate),
NotAfter: now.AddDate(0, 0, validityDays),
KeyUsage: keyUsage,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: false,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, signer.Public(), signer)
if err != nil {
return nil, nil, cosy.WrapErrorWithParams(ErrSelfSignedCreateCert, err.Error())
}
keyDER, err := x509.MarshalPKCS8PrivateKey(signer)
if err != nil {
return nil, nil, cosy.WrapErrorWithParams(ErrSelfSignedCreateCert, err.Error())
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// RegenerateSelfSigned re-issues an existing self-signed certificate for the
// auto-renewal job. It reuses the private key currently on disk when it can be
// parsed; otherwise it generates a fresh key.
func RegenerateSelfSigned(certModel *model.Cert) (certPEM, keyPEM []byte, err error) {
opts := SelfSignedOptionsFromModel(certModel)
return RegenerateSelfSignedWithOptions(certModel, opts)
}
// RegenerateSelfSignedWithOptions re-issues an existing self-signed certificate
// with caller-provided options while reusing the private key when possible.
func RegenerateSelfSignedWithOptions(certModel *model.Cert, opts SelfSignedOptions) (certPEM, keyPEM []byte, err error) {
signer, parseErr := loadSelfSignedKey(certModel.SSLCertificateKeyPath)
if parseErr != nil || signer == nil {
// Fall back to a fresh key when the existing key cannot be reused.
// Log a warning so operators notice that the certificate's public key
// (and therefore its fingerprint) has changed.
logger.Warnf("self-signed key %s could not be reused, generating a fresh key: %v",
certModel.SSLCertificateKeyPath, parseErr)
return GenerateSelfSigned(opts)
}
return signSelfSigned(opts, signer)
}
// SelfSignedOptionsFromModel builds SelfSignedOptions from a persisted Cert.
// The model's slices are defensively copied so options consumers cannot
// observe concurrent mutations on the persisted Cert.
func SelfSignedOptionsFromModel(certModel *model.Cert) SelfSignedOptions {
opts := SelfSignedOptions{
DNSNames: append([]string(nil), certModel.Domains...),
KeyType: certModel.GetKeyType(),
ValidityDays: SelfSignedDefaultValidityDays,
}
if certModel.SelfSignedConfig != nil {
opts.IPAddresses = append([]string(nil), certModel.SelfSignedConfig.IPAddresses...)
if certModel.SelfSignedConfig.ValidityDays > 0 {
opts.ValidityDays = certModel.SelfSignedConfig.ValidityDays
}
}
opts.CommonName = deriveSelfSignedCommonName(opts.DNSNames, opts.IPAddresses)
return opts
}
// deriveSelfSignedCommonName picks the certificate common name: the first DNS
// name, or the first IP address when no DNS name is present.
func deriveSelfSignedCommonName(dnsNames, ipAddresses []string) string {
for _, name := range dnsNames {
if name != "" {
return name
}
}
for _, ip := range ipAddresses {
if ip != "" {
return ip
}
}
return ""
}
// loadSelfSignedKey reads and parses the private key at the given path.
func loadSelfSignedKey(path string) (crypto.Signer, error) {
if path == "" {
return nil, ErrCertPathIsEmpty
}
pemBytes, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return certcrypto.ParsePEMPrivateKey(pemBytes)
}
+208
View File
@@ -0,0 +1,208 @@
package cert
import (
"crypto/x509"
"encoding/pem"
"net"
"os"
"path/filepath"
"testing"
"github.com/0xJacky/Nginx-UI/model"
"github.com/go-acme/lego/v5/certcrypto"
)
func parseTestCert(t *testing.T, certPEM []byte) *x509.Certificate {
t.Helper()
block, _ := pem.Decode(certPEM)
if block == nil {
t.Fatalf("failed to decode certificate PEM")
}
parsed, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("failed to parse certificate: %v", err)
}
return parsed
}
func TestGenerateSelfSignedProducesValidCertificate(t *testing.T) {
certPEM, keyPEM, err := GenerateSelfSigned(SelfSignedOptions{
CommonName: "example.com",
DNSNames: []string{"example.com", "www.example.com"},
IPAddresses: []string{"192.168.1.10"},
KeyType: certcrypto.EC256,
ValidityDays: 30,
})
if err != nil {
t.Fatalf("GenerateSelfSigned returned error: %v", err)
}
parsed := parseTestCert(t, certPEM)
if parsed.Issuer.CommonName != parsed.Subject.CommonName {
t.Fatalf("expected self-signed cert, issuer %q != subject %q",
parsed.Issuer.CommonName, parsed.Subject.CommonName)
}
if parsed.Subject.CommonName != "example.com" {
t.Fatalf("unexpected common name: %s", parsed.Subject.CommonName)
}
if len(parsed.DNSNames) != 2 || parsed.DNSNames[0] != "example.com" {
t.Fatalf("unexpected DNS names: %v", parsed.DNSNames)
}
if len(parsed.IPAddresses) != 1 || !parsed.IPAddresses[0].Equal(net.ParseIP("192.168.1.10")) {
t.Fatalf("unexpected IP addresses: %v", parsed.IPAddresses)
}
gotDays := int(parsed.NotAfter.Sub(parsed.NotBefore).Hours() / 24)
if gotDays < 29 || gotDays > 31 {
t.Fatalf("unexpected validity window: %d days", gotDays)
}
if parsed.IsCA {
t.Fatalf("leaf certificate must not be a CA")
}
if err := parsed.CheckSignature(parsed.SignatureAlgorithm,
parsed.RawTBSCertificate, parsed.Signature); err != nil {
t.Fatalf("self-signature verification failed: %v", err)
}
if !IsPrivateKey(string(keyPEM)) {
t.Fatalf("generated key is not a valid private key")
}
}
func TestGenerateSelfSignedSupportsKeyTypes(t *testing.T) {
// RSA8192 and RSA3072 are omitted: key generation is too slow under -race.
keyTypes := []certcrypto.KeyType{
certcrypto.RSA2048, certcrypto.RSA4096, certcrypto.EC256, certcrypto.EC384,
}
for _, kt := range keyTypes {
t.Run(string(kt), func(t *testing.T) {
certPEM, _, err := GenerateSelfSigned(SelfSignedOptions{
CommonName: "test.local",
DNSNames: []string{"test.local"},
KeyType: kt,
ValidityDays: 365,
})
if err != nil {
t.Fatalf("GenerateSelfSigned(%s) error: %v", kt, err)
}
parseTestCert(t, certPEM)
})
}
}
func TestGenerateSelfSignedRejectsEmptySAN(t *testing.T) {
_, _, err := GenerateSelfSigned(SelfSignedOptions{
CommonName: "example.com",
KeyType: certcrypto.EC256,
ValidityDays: 365,
})
if err == nil {
t.Fatalf("expected an error when no DNS names or IP addresses are given")
}
}
func TestGenerateSelfSignedRejectsInvalidIP(t *testing.T) {
_, _, err := GenerateSelfSigned(SelfSignedOptions{
CommonName: "example.com",
IPAddresses: []string{"not-an-ip"},
KeyType: certcrypto.EC256,
ValidityDays: 365,
})
if err == nil {
t.Fatalf("expected an error for an invalid IP address")
}
}
func TestRegenerateSelfSignedReusesKey(t *testing.T) {
_, keyPEM, err := GenerateSelfSigned(SelfSignedOptions{
CommonName: "reuse.local",
DNSNames: []string{"reuse.local"},
KeyType: certcrypto.EC256,
ValidityDays: 365,
})
if err != nil {
t.Fatalf("GenerateSelfSigned error: %v", err)
}
keyPath := filepath.Join(t.TempDir(), "private.key")
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
t.Fatalf("write key: %v", err)
}
certModel := &model.Cert{
Domains: []string{"reuse.local"},
KeyType: certcrypto.EC256,
SSLCertificateKeyPath: keyPath,
SelfSignedConfig: &model.SelfSignedCertConfig{ValidityDays: 365},
}
newCertPEM, newKeyPEM, err := RegenerateSelfSigned(certModel)
if err != nil {
t.Fatalf("RegenerateSelfSigned error: %v", err)
}
if string(newKeyPEM) != string(keyPEM) {
t.Fatalf("expected the private key to be reused unchanged")
}
parseTestCert(t, newCertPEM)
}
func TestRegenerateSelfSignedWithOptionsReusesKey(t *testing.T) {
_, keyPEM, err := GenerateSelfSigned(SelfSignedOptions{
CommonName: "old.local",
DNSNames: []string{"old.local"},
KeyType: certcrypto.EC256,
ValidityDays: 365,
})
if err != nil {
t.Fatalf("GenerateSelfSigned error: %v", err)
}
keyPath := filepath.Join(t.TempDir(), "private.key")
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
t.Fatalf("write key: %v", err)
}
certModel := &model.Cert{SSLCertificateKeyPath: keyPath}
newCertPEM, newKeyPEM, err := RegenerateSelfSignedWithOptions(certModel, SelfSignedOptions{
CommonName: "new.local",
DNSNames: []string{"new.local"},
KeyType: certcrypto.EC256,
ValidityDays: 90,
})
if err != nil {
t.Fatalf("RegenerateSelfSignedWithOptions error: %v", err)
}
if string(newKeyPEM) != string(keyPEM) {
t.Fatalf("expected the private key to be reused unchanged")
}
parsed := parseTestCert(t, newCertPEM)
if parsed.Subject.CommonName != "new.local" {
t.Fatalf("CommonName = %q, want %q", parsed.Subject.CommonName, "new.local")
}
}
func TestRegenerateSelfSignedFallsBackToFreshKey(t *testing.T) {
certModel := &model.Cert{
Domains: []string{"fresh.local"},
KeyType: certcrypto.EC256,
SSLCertificateKeyPath: filepath.Join(t.TempDir(), "missing.key"),
SelfSignedConfig: &model.SelfSignedCertConfig{ValidityDays: 365},
}
certPEM, keyPEM, err := RegenerateSelfSigned(certModel)
if err != nil {
t.Fatalf("RegenerateSelfSigned error: %v", err)
}
parseTestCert(t, certPEM)
if !IsPrivateKey(string(keyPEM)) {
t.Fatalf("fallback key is not a valid private key")
}
}
func TestDeriveSelfSignedCommonName(t *testing.T) {
if got := deriveSelfSignedCommonName([]string{"a.com", "b.com"}, nil); got != "a.com" {
t.Fatalf("expected first DNS name, got %q", got)
}
if got := deriveSelfSignedCommonName(nil, []string{"10.0.0.1"}); got != "10.0.0.1" {
t.Fatalf("expected first IP, got %q", got)
}
}
+70 -9
View File
@@ -29,11 +29,6 @@ func (c *Content) WriteFile() (err error) {
return e.NewWithParams(50006, ErrPathIsNotUnderTheNginxConfDir.Error(), c.SSLCertificateKeyPath, nginxConfPath)
}
// MkdirAll creates a directory named path, along with any necessary parents,
// and returns nil, or else returns an error.
// The permission bits perm (before umask) are used for all directories that MkdirAll creates.
// If path is already a directory, MkdirAll does nothing and returns nil.
err = os.MkdirAll(filepath.Dir(c.SSLCertificatePath), 0755)
if err != nil {
return
@@ -44,19 +39,85 @@ func (c *Content) WriteFile() (err error) {
return
}
if err = ensureWritableFileTarget(c.SSLCertificatePath); err != nil {
return
}
if err = ensureWritableFileTarget(c.SSLCertificateKeyPath); err != nil {
return
}
tmpFiles := make(map[string]string, 2)
defer func() {
for _, tmpPath := range tmpFiles {
_ = os.Remove(tmpPath)
}
}()
if c.SSLCertificate != "" {
err = os.WriteFile(c.SSLCertificatePath, []byte(c.SSLCertificate), 0755)
if err != nil {
if tmpFiles[c.SSLCertificatePath], err = writeTempFileNextTo(c.SSLCertificatePath, []byte(c.SSLCertificate), 0644); err != nil {
return
}
}
if c.SSLCertificateKey != "" {
err = os.WriteFile(c.SSLCertificateKeyPath, []byte(c.SSLCertificateKey), 0755)
if err != nil {
if tmpFiles[c.SSLCertificateKeyPath], err = writeTempFileNextTo(c.SSLCertificateKeyPath, []byte(c.SSLCertificateKey), 0600); err != nil {
return
}
}
for targetPath, tmpPath := range tmpFiles {
if err = replaceFile(tmpPath, targetPath); err != nil {
return
}
delete(tmpFiles, targetPath)
}
return
}
func ensureWritableFileTarget(path string) error {
info, err := os.Stat(path)
if err == nil && info.IsDir() {
return &os.PathError{Op: "write", Path: path, Err: os.ErrInvalid}
}
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func writeTempFileNextTo(path string, content []byte, perm os.FileMode) (string, error) {
tmpFile, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return "", err
}
tmpPath := tmpFile.Name()
if _, err = tmpFile.Write(content); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return "", err
}
if err = tmpFile.Chmod(perm); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return "", err
}
if err = tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", err
}
return tmpPath, nil
}
func replaceFile(tmpPath, targetPath string) error {
if err := os.Rename(tmpPath, targetPath); err == nil {
return nil
}
if err := os.Remove(targetPath); err != nil && !os.IsNotExist(err) {
return err
}
return os.Rename(tmpPath, targetPath)
}
+49
View File
@@ -0,0 +1,49 @@
package cert
import (
"os"
"path/filepath"
"testing"
"github.com/0xJacky/Nginx-UI/settings"
)
func TestContentWriteFileKeepsExistingPairWhenKeyWriteFails(t *testing.T) {
originalConfigDir := settings.NginxSettings.ConfigDir
confDir := t.TempDir()
settings.NginxSettings.ConfigDir = confDir
t.Cleanup(func() {
settings.NginxSettings.ConfigDir = originalConfigDir
})
certPath := filepath.Join(confDir, "ssl", "example", "fullchain.cer")
if err := os.MkdirAll(filepath.Dir(certPath), 0o755); err != nil {
t.Fatalf("create cert dir: %v", err)
}
if err := os.WriteFile(certPath, []byte("old cert"), 0o644); err != nil {
t.Fatalf("write old cert: %v", err)
}
keyPath := filepath.Join(confDir, "ssl", "example", "private.key")
if err := os.MkdirAll(keyPath, 0o755); err != nil {
t.Fatalf("create key path directory: %v", err)
}
content := &Content{
SSLCertificatePath: certPath,
SSLCertificateKeyPath: keyPath,
SSLCertificate: "new cert",
SSLCertificateKey: "new key",
}
if err := content.WriteFile(); err == nil {
t.Fatalf("expected key write failure")
}
got, err := os.ReadFile(certPath)
if err != nil {
t.Fatalf("read cert after failed write: %v", err)
}
if string(got) != "old cert" {
t.Fatalf("certificate changed after failed key write: %q", got)
}
}
+13
View File
@@ -33,3 +33,16 @@ func setupCertExpiredJob(scheduler gocron.Scheduler) (gocron.Job, error) {
}
return job, nil
}
// setupSelfSignedCertRenewalJob initializes the self-signed certificate renewal job
func setupSelfSignedCertRenewalJob(scheduler gocron.Scheduler) (gocron.Job, error) {
job, err := scheduler.NewJob(gocron.DurationJob(30*time.Minute),
gocron.NewTask(cert.RenewSelfSignedCerts),
gocron.WithSingletonMode(gocron.LimitModeWait),
gocron.JobOption(gocron.WithStartImmediately()))
if err != nil {
logger.Errorf("SelfSignedCertRenewal Job: Err: %v\n", err)
return nil, err
}
return job, nil
}
+6
View File
@@ -39,6 +39,12 @@ func InitCronJobs(ctx context.Context) {
logger.Fatalf("CertExpired Err: %v\n", err)
}
// Initialize self-signed certificate renewal job
_, err = setupSelfSignedCertRenewalJob(s)
if err != nil {
logger.Fatalf("SelfSignedCertRenewal Err: %v\n", err)
}
// Start logrotate job
setupLogrotateJob(s)
+18
View File
@@ -15,6 +15,7 @@ const (
AutoCertSync = 2
AutoCertEnabled = 1
AutoCertDisabled = -1
AutoCertSelfSigned = 3
CertChallengeMethodHTTP01 = "http01"
CertChallengeMethodDNS01 = "dns01"
@@ -36,6 +37,13 @@ type CertificateResource struct {
CSR []byte `json:"csr"`
}
// SelfSignedCertConfig stores self-signed-specific generation parameters so the
// auto-renewal job can regenerate a certificate with the same settings.
type SelfSignedCertConfig struct {
IPAddresses []string `json:"ip_addresses"`
ValidityDays int `json:"validity_days"`
}
type Cert struct {
Model
Name string `json:"name"`
@@ -56,6 +64,7 @@ type Cert struct {
MustStaple bool `json:"must_staple"`
LegoDisableCNAMESupport bool `json:"lego_disable_cname_support"`
RevokeOld bool `json:"revoke_old"`
SelfSignedConfig *SelfSignedCertConfig `json:"self_signed_config,omitempty" gorm:"serializer:json"`
LastAutoRenewAt *time.Time `json:"-"`
LastAutoRenewError string `json:"-"`
Status string `json:"status"`
@@ -82,6 +91,15 @@ 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
}
+5 -1
View File
@@ -48,6 +48,7 @@ func newCert(db *gorm.DB, opts ...gen.DOOption) cert {
_cert.MustStaple = field.NewBool(tableName, "must_staple")
_cert.LegoDisableCNAMESupport = field.NewBool(tableName, "lego_disable_cname_support")
_cert.RevokeOld = field.NewBool(tableName, "revoke_old")
_cert.SelfSignedConfig = field.NewField(tableName, "self_signed_config")
_cert.LastAutoRenewAt = field.NewTime(tableName, "last_auto_renew_at")
_cert.LastAutoRenewError = field.NewString(tableName, "last_auto_renew_error")
_cert.Status = field.NewString(tableName, "status")
@@ -94,6 +95,7 @@ type cert struct {
MustStaple field.Bool
LegoDisableCNAMESupport field.Bool
RevokeOld field.Bool
SelfSignedConfig field.Field
LastAutoRenewAt field.Time
LastAutoRenewError field.String
Status field.String
@@ -138,6 +140,7 @@ func (c *cert) updateTableName(table string) *cert {
c.MustStaple = field.NewBool(table, "must_staple")
c.LegoDisableCNAMESupport = field.NewBool(table, "lego_disable_cname_support")
c.RevokeOld = field.NewBool(table, "revoke_old")
c.SelfSignedConfig = field.NewField(table, "self_signed_config")
c.LastAutoRenewAt = field.NewTime(table, "last_auto_renew_at")
c.LastAutoRenewError = field.NewString(table, "last_auto_renew_error")
c.Status = field.NewString(table, "status")
@@ -159,7 +162,7 @@ func (c *cert) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
func (c *cert) fillFieldMap() {
c.fieldMap = make(map[string]field.Expr, 27)
c.fieldMap = make(map[string]field.Expr, 28)
c.fieldMap["id"] = c.ID
c.fieldMap["created_at"] = c.CreatedAt
c.fieldMap["updated_at"] = c.UpdatedAt
@@ -180,6 +183,7 @@ func (c *cert) fillFieldMap() {
c.fieldMap["must_staple"] = c.MustStaple
c.fieldMap["lego_disable_cname_support"] = c.LegoDisableCNAMESupport
c.fieldMap["revoke_old"] = c.RevokeOld
c.fieldMap["self_signed_config"] = c.SelfSignedConfig
c.fieldMap["last_auto_renew_at"] = c.LastAutoRenewAt
c.fieldMap["last_auto_renew_error"] = c.LastAutoRenewError
c.fieldMap["status"] = c.Status