Merge pull request 'fix: 修复编辑域名时私钥留空仍报“TLS 私钥和公钥证书必须同时填写”' (#5) from dsh/mailgo:caddy-cert-hot-reload into main
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -363,7 +363,20 @@ func readTLSCert(path string) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// normalizePEM 统一 PEM 文本的换行为 LF:浏览器提交 textarea 时会把
|
||||
// 换行规范为 CRLF,而证书文件里通常是 LF,直接比较会误判“证书已修改”
|
||||
// (表现为:私钥留空保留现有私钥时仍报“必须同时填写”)。
|
||||
func normalizePEM(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, privateKey string) error {
|
||||
// 归一化换行,保证与磁盘文件一致,避免表单往返时被误判为已修改
|
||||
publicCert = normalizePEM(strings.TrimSpace(publicCert))
|
||||
privateKey = normalizePEM(strings.TrimSpace(privateKey))
|
||||
|
||||
if !domain.TlsEnabled {
|
||||
return nil
|
||||
}
|
||||
@@ -375,7 +388,7 @@ func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, priv
|
||||
}
|
||||
return fmt.Errorf("启用 TLS 时必须填写 TLS 私钥和公钥证书")
|
||||
}
|
||||
if hasExistingCert && privateKey == "" && strings.TrimSpace(readTLSCert(domain.TlsCertPath)) == publicCert {
|
||||
if hasExistingCert && privateKey == "" && normalizePEM(strings.TrimSpace(readTLSCert(domain.TlsCertPath))) == publicCert {
|
||||
return nil
|
||||
}
|
||||
if publicCert == "" || privateKey == "" {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
)
|
||||
|
||||
// makeTestCertPair 生成一对自签名证书(PEM),可选 LF/CRLF 换行。
|
||||
func makeTestCertPair(t *testing.T, lineEnding string) (certPEM, keyPEM string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("生成私钥失败: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{"mail.example.com"},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("生成证书失败: %v", err)
|
||||
}
|
||||
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
if lineEnding == "crlf" {
|
||||
certPEM = strings.ReplaceAll(certPEM, "\n", "\r\n")
|
||||
}
|
||||
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
if lineEnding == "crlf" {
|
||||
keyPEM = strings.ReplaceAll(keyPEM, "\n", "\r\n")
|
||||
}
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
// writeExistingCert 模拟已有证书文件(LF 换行)并返回 domain。
|
||||
func writeExistingCert(t *testing.T, tlsDir string, certPEM, keyPEM string) *db.Domain {
|
||||
t.Helper()
|
||||
dir := filepath.Join(tlsDir, "1")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("创建目录失败: %v", err)
|
||||
}
|
||||
certPath := filepath.Join(dir, "cert.pem")
|
||||
keyPath := filepath.Join(dir, "key.pem")
|
||||
if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0644); err != nil {
|
||||
t.Fatalf("写入证书失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte(keyPEM+"\n"), 0600); err != nil {
|
||||
t.Fatalf("写入私钥失败: %v", err)
|
||||
}
|
||||
return &db.Domain{
|
||||
ID: 1,
|
||||
Name: "mail.example.com",
|
||||
TlsEnabled: true,
|
||||
TlsCertPath: certPath,
|
||||
TlsKeyPath: keyPath,
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDomainTLSUpdateUnchangedCertCRLF 复现用户报告的 bug:
|
||||
// 浏览器把 textarea 的 LF 换成 CRLF 提交,私钥留空(保留现有私钥),
|
||||
// 证书内容未变,此时保存不应报“必须同时填写”。
|
||||
func TestHandleDomainTLSUpdateUnchangedCertCRLF(t *testing.T) {
|
||||
certLF, keyLF := makeTestCertPair(t, "lf")
|
||||
h := &AdminHandler{tlsDir: t.TempDir()}
|
||||
domain := writeExistingCert(t, h.tlsDir, certLF, keyLF)
|
||||
|
||||
submittedCert := strings.ReplaceAll(certLF, "\n", "\r\n") // 模拟浏览器提交
|
||||
if err := h.handleDomainTLSUpdate(domain, submittedCert, ""); err != nil {
|
||||
t.Fatalf("证书未修改且私钥留空时应保留现有私钥,实际报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDomainTLSUpdateNewPairCRLF 新证书+私钥(CRLF 提交)应正常保存,
|
||||
// 且写入文件为 LF 换行、能组成有效密钥对。
|
||||
func TestHandleDomainTLSUpdateNewPairCRLF(t *testing.T) {
|
||||
certCRLF, keyCRLF := makeTestCertPair(t, "crlf")
|
||||
h := &AdminHandler{tlsDir: t.TempDir()}
|
||||
domain := &db.Domain{ID: 1, Name: "mail.example.com", TlsEnabled: true}
|
||||
|
||||
if err := h.handleDomainTLSUpdate(domain, certCRLF, keyCRLF); err != nil {
|
||||
t.Fatalf("CRLF 提交的新证书应保存成功,实际报错: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(domain.TlsCertPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读取保存的证书失败: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "\r") {
|
||||
t.Error("保存的证书文件不应包含 CR")
|
||||
}
|
||||
if _, err := tls.LoadX509KeyPair(domain.TlsCertPath, domain.TlsKeyPath); err != nil {
|
||||
t.Fatalf("保存的证书对无效: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDomainTLSUpdateChangedCertWithoutKey 证书确实被修改但私钥留空,
|
||||
// 应报“必须同时填写”(防止用不匹配的私钥)。
|
||||
func TestHandleDomainTLSUpdateChangedCertWithoutKey(t *testing.T) {
|
||||
certA, keyA := makeTestCertPair(t, "lf")
|
||||
certB, _ := makeTestCertPair(t, "lf")
|
||||
h := &AdminHandler{tlsDir: t.TempDir()}
|
||||
domain := writeExistingCert(t, h.tlsDir, certA, keyA)
|
||||
|
||||
err := h.handleDomainTLSUpdate(domain, certB, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "必须同时填写") {
|
||||
t.Fatalf("修改证书但私钥留空应报“必须同时填写”,实际: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user