mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-05 02:57:18 +08: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>
285 lines
8.3 KiB
Go
285 lines
8.3 KiB
Go
package certificate
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"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"
|
|
)
|
|
|
|
type APICertificate struct {
|
|
*model.Cert
|
|
SSLCertificate string `json:"ssl_certificate,omitempty"`
|
|
SSLCertificateKey string `json:"ssl_certificate_key,omitempty"`
|
|
CertificateInfo *cert.Info `json:"certificate_info,omitempty"`
|
|
}
|
|
|
|
func Transformer(certModel *model.Cert) (certificate *APICertificate) {
|
|
var sslCertificationBytes, sslCertificationKeyBytes []byte
|
|
var certificateInfo *cert.Info
|
|
if certModel.SSLCertificatePath != "" &&
|
|
helper.IsUnderDirectory(certModel.SSLCertificatePath, nginx.GetConfPath()) {
|
|
if _, err := os.Stat(certModel.SSLCertificatePath); err == nil {
|
|
sslCertificationBytes, _ = os.ReadFile(certModel.SSLCertificatePath)
|
|
if !cert.IsCertificate(string(sslCertificationBytes)) {
|
|
sslCertificationBytes = []byte{}
|
|
}
|
|
}
|
|
|
|
certificateInfo, _ = cert.GetCertInfo(certModel.SSLCertificatePath)
|
|
}
|
|
|
|
if certModel.SSLCertificateKeyPath != "" &&
|
|
helper.IsUnderDirectory(certModel.SSLCertificateKeyPath, nginx.GetConfPath()) {
|
|
if _, err := os.Stat(certModel.SSLCertificateKeyPath); err == nil {
|
|
sslCertificationKeyBytes, _ = os.ReadFile(certModel.SSLCertificateKeyPath)
|
|
if !cert.IsPrivateKey(string(sslCertificationKeyBytes)) {
|
|
sslCertificationKeyBytes = []byte{}
|
|
}
|
|
}
|
|
}
|
|
|
|
return &APICertificate{
|
|
Cert: certModel,
|
|
SSLCertificate: string(sslCertificationBytes),
|
|
SSLCertificateKey: string(sslCertificationKeyBytes),
|
|
CertificateInfo: certificateInfo,
|
|
}
|
|
}
|
|
|
|
func GetCertList(c *gin.Context) {
|
|
s := logger.NewSessionLogger(c)
|
|
s.Info("GetCertList")
|
|
cosy.Core[model.Cert](c).SetFussy("name", "domain").
|
|
SetTransformer(func(m *model.Cert) any {
|
|
info, _ := cert.GetCertInfo(m.SSLCertificatePath)
|
|
return APICertificate{
|
|
Cert: m,
|
|
CertificateInfo: info,
|
|
}
|
|
}).PagingList()
|
|
}
|
|
|
|
func GetCert(c *gin.Context) {
|
|
q := query.Cert
|
|
|
|
id := cast.ToUint64(c.Param("id"))
|
|
if contextId, ok := c.Get("id"); ok {
|
|
id = cast.ToUint64(contextId)
|
|
}
|
|
|
|
certModel, err := q.FirstByID(id)
|
|
|
|
if err != nil {
|
|
cosy.ErrHandler(c, err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, Transformer(certModel))
|
|
}
|
|
|
|
func normalizeCertKeyType(ctx *cosy.Ctx[model.Cert]) {
|
|
payloadKeyType := cast.ToString(ctx.Payload["key_type"])
|
|
if payloadKeyType != "" {
|
|
ctx.Model.KeyType = helper.GetKeyType(certcrypto.KeyType(payloadKeyType))
|
|
}
|
|
|
|
sslCertificate := cast.ToString(ctx.Payload["ssl_certificate"])
|
|
if sslCertificate == "" {
|
|
return
|
|
}
|
|
|
|
keyType, err := cert.GetKeyType(sslCertificate)
|
|
if err == nil && keyType != "" {
|
|
ctx.Model.KeyType = helper.GetKeyType(certcrypto.KeyType(keyType))
|
|
}
|
|
}
|
|
|
|
func AddCert(c *gin.Context) {
|
|
cosy.Core[model.Cert](c).
|
|
SetValidRules(gin.H{
|
|
"name": "omitempty",
|
|
"ssl_certificate_path": "required,certificate_path",
|
|
"ssl_certificate_key_path": "required,privatekey_path",
|
|
"ssl_certificate": "omitempty,certificate",
|
|
"ssl_certificate_key": "omitempty,privatekey",
|
|
"key_type": "omitempty,auto_cert_key_type",
|
|
"challenge_method": "omitempty,oneof=http01 dns01",
|
|
"dns_credential_id": "omitempty",
|
|
"acme_user_id": "omitempty",
|
|
"sync_node_ids": "omitempty",
|
|
"must_staple": "omitempty",
|
|
"lego_disable_cname_support": "omitempty",
|
|
"revoke_old": "omitempty",
|
|
}).
|
|
BeforeExecuteHook(func(ctx *cosy.Ctx[model.Cert]) {
|
|
normalizeCertKeyType(ctx)
|
|
}).
|
|
ExecutedHook(func(ctx *cosy.Ctx[model.Cert]) {
|
|
sslCertificate := cast.ToString(ctx.Payload["ssl_certificate"])
|
|
sslCertificateKey := cast.ToString(ctx.Payload["ssl_certificate_key"])
|
|
if sslCertificate != "" && sslCertificateKey != "" {
|
|
content := &cert.Content{
|
|
SSLCertificatePath: ctx.Model.SSLCertificatePath,
|
|
SSLCertificateKeyPath: ctx.Model.SSLCertificateKeyPath,
|
|
SSLCertificate: sslCertificate,
|
|
SSLCertificateKey: sslCertificateKey,
|
|
}
|
|
err := content.WriteFile()
|
|
if err != nil {
|
|
ctx.AbortWithError(err)
|
|
return
|
|
}
|
|
}
|
|
err := cert.SyncToRemoteServer(&ctx.Model)
|
|
if err != nil {
|
|
notification.Error("Sync Certificate Error", err.Error(), nil)
|
|
return
|
|
}
|
|
ctx.Context.Set("id", ctx.Model.ID)
|
|
}).
|
|
SetNextHandler(GetCert).
|
|
Create()
|
|
}
|
|
|
|
func ModifyCert(c *gin.Context) {
|
|
cosy.Core[model.Cert](c).
|
|
SetValidRules(gin.H{
|
|
"name": "omitempty",
|
|
"ssl_certificate_path": "required,certificate_path",
|
|
"ssl_certificate_key_path": "required,privatekey_path",
|
|
"ssl_certificate": "omitempty,certificate",
|
|
"ssl_certificate_key": "omitempty,privatekey",
|
|
"key_type": "omitempty,auto_cert_key_type",
|
|
"challenge_method": "omitempty,oneof=http01 dns01",
|
|
"dns_credential_id": "omitempty",
|
|
"acme_user_id": "omitempty",
|
|
"sync_node_ids": "omitempty",
|
|
"must_staple": "omitempty",
|
|
"lego_disable_cname_support": "omitempty",
|
|
"revoke_old": "omitempty",
|
|
}).
|
|
BeforeExecuteHook(func(ctx *cosy.Ctx[model.Cert]) {
|
|
normalizeCertKeyType(ctx)
|
|
}).
|
|
ExecutedHook(func(ctx *cosy.Ctx[model.Cert]) {
|
|
sslCertificate := cast.ToString(ctx.Payload["ssl_certificate"])
|
|
sslCertificateKey := cast.ToString(ctx.Payload["ssl_certificate_key"])
|
|
|
|
content := &cert.Content{
|
|
SSLCertificatePath: ctx.Model.SSLCertificatePath,
|
|
SSLCertificateKeyPath: ctx.Model.SSLCertificateKeyPath,
|
|
SSLCertificate: sslCertificate,
|
|
SSLCertificateKey: sslCertificateKey,
|
|
}
|
|
err := content.WriteFile()
|
|
if err != nil {
|
|
ctx.AbortWithError(err)
|
|
return
|
|
}
|
|
err = cert.SyncToRemoteServer(&ctx.Model)
|
|
if err != nil {
|
|
notification.Error("Sync Certificate Error", err.Error(), nil)
|
|
return
|
|
}
|
|
}).
|
|
SetNextHandler(GetCert).
|
|
Modify()
|
|
}
|
|
|
|
func RemoveCert(c *gin.Context) {
|
|
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) {
|
|
var json cert.SyncCertificatePayload
|
|
|
|
if !cosy.BindAndValid(c, &json) {
|
|
return
|
|
}
|
|
normalizedKeyType := helper.GetKeyType(json.KeyType)
|
|
|
|
certModel := &model.Cert{
|
|
Name: json.Name,
|
|
SSLCertificatePath: json.SSLCertificatePath,
|
|
SSLCertificateKeyPath: json.SSLCertificateKeyPath,
|
|
KeyType: normalizedKeyType,
|
|
AutoCert: model.AutoCertSync,
|
|
}
|
|
|
|
db := model.UseDB()
|
|
|
|
err := db.Where("name = ? AND ssl_certificate_path = ? AND ssl_certificate_key_path = ? AND key_type IN ?",
|
|
json.Name, json.SSLCertificatePath, json.SSLCertificateKeyPath,
|
|
helper.GetKeyTypeAliasStrings(normalizedKeyType)).
|
|
Assign(&model.Cert{KeyType: normalizedKeyType}).
|
|
FirstOrCreate(certModel).Error
|
|
if err != nil {
|
|
cosy.ErrHandler(c, err)
|
|
return
|
|
}
|
|
|
|
content := &cert.Content{
|
|
SSLCertificatePath: json.SSLCertificatePath,
|
|
SSLCertificateKeyPath: json.SSLCertificateKeyPath,
|
|
SSLCertificate: json.SSLCertificate,
|
|
SSLCertificateKey: json.SSLCertificateKey,
|
|
}
|
|
|
|
err = content.WriteFile()
|
|
if err != nil {
|
|
cosy.ErrHandler(c, err)
|
|
return
|
|
}
|
|
|
|
nginx.Reload()
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "ok",
|
|
})
|
|
}
|