From eed0c7b95fc52c5d4f883e49734ca86cdc97f78c Mon Sep 17 00:00:00 2001 From: dsh Date: Sun, 16 Aug 2026 23:45:44 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=9F=9F=E5=90=8D=E6=97=B6=E7=A7=81=E9=92=A5=E7=95=99=E7=A9=BA?= =?UTF-8?q?=E4=BB=8D=E6=8A=A5=E2=80=9CTLS=20=E7=A7=81=E9=92=A5=E5=92=8C?= =?UTF-8?q?=E5=85=AC=E9=92=A5=E8=AF=81=E4=B9=A6=E5=BF=85=E9=A1=BB=E5=90=8C?= =?UTF-8?q?=E6=97=B6=E5=A1=AB=E5=86=99=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 浏览器提交 textarea 会把换行规范为 CRLF,而证书文件(尤其是一键从 Caddy 导入的)是 LF,导致“证书未修改”的比较失效,私钥留空(保留 现有私钥)时误报必须同时填写。 - handleDomainTLSUpdate 增加 normalizePEM:提交值与磁盘文件统一 归一化为 LF 后再比较/写入 - 新增 3 个回归测试:CRLF 提交证书未变+私钥留空、CRLF 新证书对 保存为 LF、证书已变但私钥留空仍应报错 --- internal/web/handlers/admin.go | 15 ++- internal/web/handlers/admin_tls_test.go | 126 ++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 internal/web/handlers/admin_tls_test.go diff --git a/internal/web/handlers/admin.go b/internal/web/handlers/admin.go index 50f79cc..4b1db89 100644 --- a/internal/web/handlers/admin.go +++ b/internal/web/handlers/admin.go @@ -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 == "" { diff --git a/internal/web/handlers/admin_tls_test.go b/internal/web/handlers/admin_tls_test.go new file mode 100644 index 0000000..68e5b26 --- /dev/null +++ b/internal/web/handlers/admin_tls_test.go @@ -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) + } +}