package ssl import ( "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "math/big" "os" "path/filepath" "testing" "time" "git.pcux.cn/xuans/Smail/config" "github.com/go-acme/lego/v4/certificate" ) // 构造根、中间和叶证书,模拟 ACME 的完整链响应;不请求真实 CA。 func auditCertificateChain(t *testing.T, domain string, extraDomains ...string) certificate.Resource { t.Helper() createKey := func() *ecdsa.PrivateKey { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } return key } rootKey, issuerKey, leafKey := createKey(), createKey(), createKey() makeTemplate := func(serial int64, name string, ca bool) *x509.Certificate { return &x509.Certificate{ SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: name}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour), IsCA: ca, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, } } root := makeTemplate(1, "Audit Root", true) issuer := makeTemplate(2, "Audit Intermediate", true) leaf := makeTemplate(3, domain, false) leaf.DNSNames = []string{domain} leaf.DNSNames = append(leaf.DNSNames, extraDomains...) leaf.KeyUsage = x509.KeyUsageDigitalSignature leaf.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} encode := func(template, parent *x509.Certificate, key, signingKey *ecdsa.PrivateKey) []byte { der, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, signingKey) if err != nil { t.Fatal(err) } return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) } issuerPEM := encode(issuer, root, issuerKey, rootKey) leafPEM := encode(leaf, issuer, leafKey, issuerKey) privateDER, err := x509.MarshalECPrivateKey(leafKey) if err != nil { t.Fatal(err) } return certificate.Resource{ PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateDER}), Certificate: append(leafPEM, issuerPEM...), IssuerCertificate: issuerPEM, } } func auditSSLRoot(t *testing.T) { t.Helper() oldRoot, oldConfig, oldSnapshot := config.ROOT_PATH, config.Instance, config.CurrentConfig() oldManager := config.CurrentManager() config.ROOT_PATH = t.TempDir() config.SetRuntimeManager(nil) t.Cleanup(func() { config.ROOT_PATH, config.Instance = oldRoot, oldConfig config.PublishConfigSnapshot(oldSnapshot) config.SetRuntimeManager(oldManager) }) } func TestAuditACMEBundledChainMustBeImportable(t *testing.T) { auditSSLRoot(t) resource := auditCertificateChain(t, "imap.example.test") if _, err := tls.X509KeyPair(resource.Certificate, resource.PrivateKey); err != nil { t.Fatal(err) } if err := config.ValidateSSLMaterial(resource.PrivateKey, resource.Certificate, nil, []string{"imap.example.test"}); err != nil { t.Fatalf("正常完整链对照失败: %v", err) } cfg := &config.Config{Domain: "imap.example.test", SSLType: config.SSLTypeAutoHTTP} err := writeCertificateFiles(cfg, resource.PrivateKey, resource.Certificate, resource.IssuerCertificate) t.Logf("bundle_only_valid=true bundle_plus_issuer_write_error=%v", err) if err != nil { t.Fatalf("ACME Bundle=true 的完整链与单独 issuer 字段不应导致正常签发结果被拒绝: %v", err) } } func TestAuditFreshWildcardCertificateMustNotRequireRenewal(t *testing.T) { auditSSLRoot(t) cfg := &config.Config{Domain: "example.test", WebDomain: "web.example.test", SSLType: config.SSLTypeAutoDNS} resource := auditCertificateChain(t, "*.example.test") // 不再附加 issuer,以隔离完整链重复问题和续期判断问题。 if err := writeCertificateFiles(cfg, resource.PrivateKey, resource.Certificate, nil); err != nil { t.Fatal(err) } pair, err := config.LoadRuntimeTLSCertificate(cfg) if err != nil { t.Fatal(err) } leaf, err := x509.ParseCertificate(pair.Certificate[0]) if err != nil { t.Fatal(err) } if err := leaf.VerifyHostname("imap.example.test"); err != nil { t.Fatalf("泛域名覆盖 IMAP 的前提不成立: %v", err) } days, _, mismatch, err := CheckSSLCrtInfo() if err != nil { t.Fatal(err) } t.Logf("days_remaining=%d imap_hostname_valid=true name_match_fail=%t renewal_predicate=%t", days, mismatch, days < 30 || mismatch) if days < 30 || mismatch { t.Fatal("新签发且覆盖服务域名的泛域名证书不应立即触发续期") } } // 只在临时根目录建立受控证书和真实分区配置,不接触运行中的实例。 func auditMigratedSSL(t *testing.T, mode string) (*config.Config, []byte) { t.Helper() oldManager := config.CurrentManager() config.SetRuntimeManager(nil) t.Cleanup(func() { config.SetRuntimeManager(oldManager) }) auditSSLRoot(t) resource := auditCertificateChain(t, "imap.example.test", "smtp.imap.example.test", "pop.imap.example.test", "imap.imap.example.test") reference, err := config.ImportManagedSSL(config.ManagedSSLModeDefault, resource.PrivateKey, resource.Certificate, nil, nil) if err != nil { t.Fatal(err) } legacy := map[string]any{ "logLevel": "info", "domain": "imap.example.test", "webDomain": "imap.example.test", "dbType": "sqlite", "dbDSN": "./config/review.db", "isInit": true, "sslType": mode, "SSLPublicKeyPath": reference.CertificateRef, "SSLPrivateKeyPath": reference.PrivateKeyRef, "httpPort": 80, "httpsPort": 443, "httpsEnabled": 1, } data, err := json.Marshal(legacy) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(config.ROOT_PATH, "config", "config.json"), data, 0600); err != nil { t.Fatal(err) } if _, err := config.MigrateLegacyConfig(config.ROOT_PATH, config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil { t.Fatal(err) } cfg, err := config.ReadConfig() if err != nil { t.Fatal(err) } if !cfg.IsSetupReady() { t.Fatal("分区配置未达到可用状态,无法验证续期路径") } return cfg, resource.Certificate } func TestAuditPartitionedRenewalMustCommitBeforeSwitch(t *testing.T) { cfg, original := auditMigratedSSL(t, config.SSLTypeAutoHTTP) loaded, err := config.LoadRuntimeTLSCertificate(cfg) if err != nil { t.Fatal(err) } rootPath := filepath.Join(config.ROOT_PATH, "config", "config.json") before, err := os.ReadFile(rootPath) if err != nil { t.Fatal(err) } resource := auditCertificateChain(t, "imap.example.test", "smtp.imap.example.test", "pop.imap.example.test", "imap.imap.example.test") writeErr := writeCertificateFiles(cfg, resource.PrivateKey, resource.Certificate, nil) after, err := os.ReadFile(rootPath) if err != nil { t.Fatal(err) } persisted, err := config.ReadConfig() if err != nil { t.Fatal(err) } certificatePath, _, err := config.ResolveRuntimeSSLPaths(persisted) if err != nil { t.Fatal(err) } current, err := os.ReadFile(certificatePath) if err != nil { t.Fatal(err) } currentPair, err := config.LoadRuntimeTLSCertificate(persisted) if err != nil { t.Fatal(err) } t.Logf("write_error=%v root_unchanged=%t current_changed=%t current_is_new=%t loaded_tls_is_old=%t", writeErr, bytes.Equal(before, after), !bytes.Equal(original, current), bytes.Equal(current, resource.Certificate), !bytes.Equal(loaded.Certificate[0], currentPair.Certificate[0])) if writeErr != nil { t.Fatal("分区配置的自动续期仍调用旧写入接口,失败后受控证书已经切换") } } func TestAuditPartitionedMigrationMustPreserveDNSChallengeMode(t *testing.T) { cfg, _ := auditMigratedSSL(t, config.SSLTypeAutoDNS) t.Logf("before_ssl_type=%s after_ssl_type=%s before_dns_challenge=true after_http_challenge=%t", config.SSLTypeAutoDNS, cfg.SSLType, cfg.SSLType == config.SSLTypeAutoHTTP) if cfg.SSLType != config.SSLTypeAutoDNS { t.Fatal("自动 DNS 配置迁移后不应静默改成 HTTP 挑战") } }