Files
Jacky 69cfa82b1d feat: self-signed certificate support (#1655) (#1688)
* feat(cert): add self-signed certificate type and config to model

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(cert): apply code-review cleanup

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

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

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

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

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

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

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

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

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

* fix(site): satisfy strict listen regex lint

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(ui): add StringListInput component

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor(cert): make SelfSignedCertPayload.name required

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

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

* refactor(cert): use StringListInput for Custom Domains

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

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

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

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

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

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

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

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

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

Fixes #1697.

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

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

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

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

* chore: update translations

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Hintay <hintay@me.com>
2026-05-24 09:22:21 +08:00

268 lines
7.8 KiB
Go

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
}