mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
* 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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -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
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+749
-650
File diff suppressed because it is too large
Load Diff
+389
-321
File diff suppressed because it is too large
Load Diff
+733
-629
File diff suppressed because it is too large
Load Diff
+732
-632
File diff suppressed because it is too large
Load Diff
+746
-863
File diff suppressed because it is too large
Load Diff
+711
-772
File diff suppressed because it is too large
Load Diff
+358
-319
File diff suppressed because it is too large
Load Diff
+692
-584
File diff suppressed because it is too large
Load Diff
+720
-602
File diff suppressed because it is too large
Load Diff
+711
-607
File diff suppressed because it is too large
Load Diff
+702
-586
File diff suppressed because it is too large
Load Diff
+696
-593
File diff suppressed because it is too large
Load Diff
+653
-661
File diff suppressed because it is too large
Load Diff
+655
-663
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 1–105) 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 ~122–129) 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 ~183–200):
|
||||
|
||||
```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 1–25 (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 27–91) 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.
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user