From a4dffb04d48dc0b96f14238964d96457b0c63b89 Mon Sep 17 00:00:00 2001 From: xuans Date: Tue, 15 Sep 2026 20:17:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E8=AF=81=E4=B9=A6=E4=BA=8B=E5=8A=A1=E5=92=8C=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E4=BA=92=E6=96=A5=E5=B9=B6=E7=BA=B3=E5=85=A5?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E6=9E=84=E5=BB=BA=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + backend/internal/certificate/service.go | 6 +- backend/internal/certificate/service_test.go | 3 + backend/internal/certificate/trust.go | 54 +++ .../internal/certificate/trust/staging-x1.pem | 32 ++ .../internal/certificate/trust/staging-x2.pem | 15 + .../internal/certificate/trust/staging-ye.pem | 13 + .../internal/certificate/trust/staging-yr.pem | 31 ++ .../certificate/trust/来源说明.md | 21 + backend/internal/certificate/trust_test.go | 71 ++++ backend/internal/certificate/validation.go | 11 +- .../internal/certificate/validation_test.go | 16 + backend/internal/cli/init_certificate.go | 118 ++++++ backend/internal/cli/init_certificate_test.go | 184 +++++++++ backend/internal/cli/root.go | 30 +- backend/internal/deployment/client.go | 2 + .../deployment/remote_acceptance_test.go | 3 + backend/internal/deployment/service.go | 76 +++- backend/internal/deployment/service_test.go | 18 + backend/internal/deployment/ssh.go | 28 +- .../internal/deployment/ssh_safety_test.go | 30 ++ .../internal/deployment/transaction_test.go | 110 +++++ backend/internal/httpapi/setup.go | 1 + backend/internal/setup/setup.go | 5 +- backend/internal/setup/setup_test.go | 4 +- build/README.md | 47 +++ build/build-release.ps1 | 389 ++++++++++++++++++ 27 files changed, 1294 insertions(+), 25 deletions(-) create mode 100644 backend/internal/certificate/trust.go create mode 100644 backend/internal/certificate/trust/staging-x1.pem create mode 100644 backend/internal/certificate/trust/staging-x2.pem create mode 100644 backend/internal/certificate/trust/staging-ye.pem create mode 100644 backend/internal/certificate/trust/staging-yr.pem create mode 100644 backend/internal/certificate/trust/来源说明.md create mode 100644 backend/internal/certificate/trust_test.go create mode 100644 backend/internal/cli/init_certificate.go create mode 100644 backend/internal/cli/init_certificate_test.go create mode 100644 backend/internal/deployment/transaction_test.go create mode 100644 build/README.md create mode 100644 build/build-release.ps1 diff --git a/.gitignore b/.gitignore index 7bea43b..377266d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ backend/build/ backend/bin/ frontend/node_modules/ frontend/dist/ +frontend/dist-release-*/ *.log .runtime/ build/* diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index ec08f29..683995c 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -2,6 +2,7 @@ package certificate import ( "context" + "crypto/x509" "errors" "fmt" "os" @@ -55,6 +56,7 @@ type Service struct { store FileStore newIssuer IssuerFactory preferredChain string + acmeRoots *x509.CertPool } func (s *Service) SetDNSAuthorizationChecker(checker DNSAuthorizationChecker) { @@ -181,7 +183,7 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro if err != nil { return fail(err) } - if err := validateIssuedResource(ctx, resource, domains); err != nil { + if err := s.validateACMEResource(ctx, resource, domains, item.ACMEEnvironment); err != nil { return fail(err) } saved, err := s.store.SaveForType(ctx, item.Name, string(item.CertificateType), resource) @@ -258,7 +260,7 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq if err != nil { return SavedResource{}, fmt.Errorf("初始化证书校验失败:%w", err) } - if err := validateIssuedResource(ctx, resource, domains); err != nil { + if err := s.validateACMEResource(ctx, resource, domains, environment); err != nil { return SavedResource{}, err } store := FileStore{Root: request.CertificateRoot, BackupRoot: request.BackupRoot} diff --git a/backend/internal/certificate/service_test.go b/backend/internal/certificate/service_test.go index 3b72ce8..c275923 100644 --- a/backend/internal/certificate/service_test.go +++ b/backend/internal/certificate/service_test.go @@ -112,6 +112,7 @@ func TestServiceIssuesSANCertificateAndUpdatesState(t *testing.T) { repository := &serviceRepository{item: item} issuer := &serviceIssuer{resource: testResourceForDomains(t, []string{"example.com", "www.example.com"})} service := NewService(cfg, repository, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) + service.acmeRoots = testResourceRoots(t, issuer.resource) service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { return issuer } result, err := service.Issue(context.Background(), item.ID) @@ -361,6 +362,7 @@ func TestServiceIssuesSetupCertificateWithTypedLayout(t *testing.T) { cfg := certificateTestConfig(root) issuer := &serviceIssuer{resource: testResource(t, "scert.example.com")} service := NewService(cfg, nil, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) + service.acmeRoots = testResourceRoots(t, issuer.resource) service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { return issuer } saved, err := service.IssueForSetup(context.Background(), SetupCertificateRequest{ @@ -408,6 +410,7 @@ func TestServiceDecryptsCredentialReferenceBeforeCreatingProvider(t *testing.T) factory := &recordingProviderFactory{} issuer := &serviceIssuer{resource: testResource(t, "example.com")} service := NewService(cfg, repository, store, factory, serviceAccounts{}) + service.acmeRoots = testResourceRoots(t, issuer.resource) service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { return issuer } if _, err := service.Issue(context.Background(), item.ID); err != nil { diff --git a/backend/internal/certificate/trust.go b/backend/internal/certificate/trust.go new file mode 100644 index 0000000..2d2f031 --- /dev/null +++ b/backend/internal/certificate/trust.go @@ -0,0 +1,54 @@ +package certificate + +import ( + "context" + "crypto/x509" + "embed" + "fmt" + "strings" +) + +// 测试根仅供 ACME 测试签发结果校验,不加入系统、HTTP 客户端或正式环境信任库。 +// +//go:embed trust/*.pem +var stagingTrust embed.FS + +func acmeTrustRoots(environment string) (*x509.CertPool, error) { + switch strings.ToLower(strings.TrimSpace(environment)) { + case "production": + return nil, nil + case "staging": + roots := x509.NewCertPool() + entries, err := stagingTrust.ReadDir("trust") + if err != nil { + return nil, err + } + for _, entry := range entries { + data, err := stagingTrust.ReadFile("trust/" + entry.Name()) + if err != nil || !roots.AppendCertsFromPEM(data) { + return nil, fmt.Errorf("内置 ACME 测试根证书无效:%s", entry.Name()) + } + } + return roots, nil + default: + return nil, fmt.Errorf("ACME 环境无效:%s", environment) + } +} + +func (s *Service) validateACMEResource(ctx context.Context, resource Resource, domains []string, environment string) error { + roots, err := acmeTrustRoots(environment) + if err != nil { + return err + } + if s.acmeRoots != nil { + roots = s.acmeRoots + } + chain := append([]byte(nil), resource.Certificate...) + chain = append(chain, resource.IssuerCertificate...) + // 即使签发器未返回单独的 issuer 字段,也必须验证完整信任链。 + _, err = (LocalValidator{Roots: roots, VerifyChain: true}).ValidatePEM(ctx, chain, domains) + if err != nil { + return fmt.Errorf("ACME %s 证书信任链校验失败:%w", environment, err) + } + return nil +} diff --git a/backend/internal/certificate/trust/staging-x1.pem b/backend/internal/certificate/trust/staging-x1.pem new file mode 100644 index 0000000..37655b2 --- /dev/null +++ b/backend/internal/certificate/trust/staging-x1.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFmDCCA4CgAwIBAgIQU9C87nMpOIFKYpfvOHFHFDANBgkqhkiG9w0BAQsFADBm +MQswCQYDVQQGEwJVUzEzMDEGA1UEChMqKFNUQUdJTkcpIEludGVybmV0IFNlY3Vy +aXR5IFJlc2VhcmNoIEdyb3VwMSIwIAYDVQQDExkoU1RBR0lORykgUHJldGVuZCBQ +ZWFyIFgxMB4XDTE1MDYwNDExMDQzOFoXDTM1MDYwNDExMDQzOFowZjELMAkGA1UE +BhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1cml0eSBSZXNl +YXJjaCBHcm91cDEiMCAGA1UEAxMZKFNUQUdJTkcpIFByZXRlbmQgUGVhciBYMTCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALbagEdDTa1QgGBWSYkyMhsc +ZXENOBaVRTMX1hceJENgsL0Ma49D3MilI4KS38mtkmdF6cPWnL++fgehT0FbRHZg +jOEr8UAN4jH6omjrbTD++VZneTsMVaGamQmDdFl5g1gYaigkkmx8OiCO68a4QXg4 +wSyn6iDipKP8utsE+x1E28SA75HOYqpdrk4HGxuULvlr03wZGTIf/oRt2/c+dYmD +oaJhge+GOrLAEQByO7+8+vzOwpNAPEx6LW+crEEZ7eBXih6VP19sTGy3yfqK5tPt +TdXXCOQMKAp+gCj/VByhmIr+0iNDC540gtvV303WpcbwnkkLYC0Ft2cYUyHtkstO +fRcRO+K2cZozoSwVPyB8/J9RpcRK3jgnX9lujfwA/pAbP0J2UPQFxmWFRQnFjaq6 +rkqbNEBgLy+kFL1NEsRbvFbKrRi5bYy2lNms2NJPZvdNQbT/2dBZKmJqxHkxCuOQ +FjhJQNeO+Njm1Z1iATS/3rts2yZlqXKsxQUzN6vNbD8KnXRMEeOXUYvbV4lqfCf8 +mS14WEbSiMy87GB5S9ucSV1XUrlTG5UGcMSZOBcEUpisRPEmQWUOTWIoDQ5FOia/ +GI+Ki523r2ruEmbmG37EBSBXdxIdndqrjy+QVAmCebyDx9eVEGOIpn26bW5LKeru +mJxa/CFBaKi4bRvmdJRLAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB +Af8EBTADAQH/MB0GA1UdDgQWBBS182Xy/rAKkh/7PH3zRKCsYyXDFDANBgkqhkiG +9w0BAQsFAAOCAgEAncDZNytDbrrVe68UT6py1lfF2h6Tm2p8ro42i87WWyP2LK8Y +nLHC0hvNfWeWmjZQYBQfGC5c7aQRezak+tHLdmrNKHkn5kn+9E9LCjCaEsyIIn2j +qdHlAkepu/C3KnNtVx5tW07e5bvIjJScwkCDbP3akWQixPpRFAsnP+ULx7k0aO1x +qAeaAhQ2rgo1F58hcflgqKTXnpPM02intVfiVVkX5GXpJjK5EoQtLceyGOrkxlM/ +sTPq4UrnypmsqSagWV3HcUlYtDinc+nukFk6eR4XkzXBbwKajl0YjztfrCIHOn5Q +CJL6TERVDbM/aAPly8kJ1sWGLuvvWYzMYgLzDul//rUF10gEMWaXVZV51KpS9DY/ +5CunuvCXmEQJHo7kGcViT7sETn6Jz9KOhvYcXkJ7po6d93A/jy4GKPIPnsKKNEmR +xUuXY4xRdh45tMJnLTUDdC9FIU0flTeO9/vNpVA8OPU1i14vCz+MU8KX1bV3GXm/ +fxlB7VBBjX9v5oUep0o/j68R/iDlCOM4VVfRa8gX6T2FU7fNdatvGro7uQzIvWof +gN9WUwCbEMBy/YhBSrXycKA8crgGg3x1mIsopn88JKwmMBa68oS7EHM9w7C4y71M +7DiA+/9Qdp9RBWJpTS9i/mDnJg1xvo8Xz49mrrgfmcAXTCJqXi24NatI3Oc= +-----END CERTIFICATE----- diff --git a/backend/internal/certificate/trust/staging-x2.pem b/backend/internal/certificate/trust/staging-x2.pem new file mode 100644 index 0000000..f627e1d --- /dev/null +++ b/backend/internal/certificate/trust/staging-x2.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICTjCCAdSgAwIBAgIRAIPgc3k5LlLVLtUUvs4K/QcwCgYIKoZIzj0EAwMwaDEL +MAkGA1UEBhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1cml0 +eSBSZXNlYXJjaCBHcm91cDEkMCIGA1UEAxMbKFNUQUdJTkcpIEJvZ3VzIEJyb2Nj +b2xpIFgyMB4XDTIwMDkwNDAwMDAwMFoXDTQwMDkxNzE2MDAwMFowaDELMAkGA1UE +BhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1cml0eSBSZXNl +YXJjaCBHcm91cDEkMCIGA1UEAxMbKFNUQUdJTkcpIEJvZ3VzIEJyb2Njb2xpIFgy +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEOvS+w1kCzAxYOJbA06Aw0HFP2tLBLKPo +FQqR9AMskl1nC2975eQqycR+ACvYelA8rfwFXObMHYXJ23XLB+dAjPJVOJ2OcsjT +VqO4dcDWu+rQ2VILdnJRYypnV1MMThVxo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU3tGjWWQOwZo2o0busBB2766XlWYwCgYI +KoZIzj0EAwMDaAAwZQIwRcp4ZKBsq9XkUuN8wfX+GEbY1N5nmCRc8e80kUkuAefo +uc2j3cICeXo1cOybQ1iWAjEA3Ooawl8eQyR4wrjCofUE8h44p0j7Yl/kBlJZT8+9 +vbtH7QiVzeKCOTQPINyRql6P +-----END CERTIFICATE----- diff --git a/backend/internal/certificate/trust/staging-ye.pem b/backend/internal/certificate/trust/staging-ye.pem new file mode 100644 index 0000000..c9d68c5 --- /dev/null +++ b/backend/internal/certificate/trust/staging-ye.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIICDDCCAZKgAwIBAgIRAJacQQzEKTdLO8Jbc8CHVlQwCgYIKoZIzj0EAwMwRzEL +MAkGA1UEBhMCVVMxDTALBgNVBAoTBElTUkcxKTAnBgNVBAMTIChTVEFHSU5HKSBZ +ZWFybmluZyBZdWNjYSBSb290IFlFMB4XDTI1MDkwMzAwMDAwMFoXDTQ1MDkwMjIz +NTk1OVowRzELMAkGA1UEBhMCVVMxDTALBgNVBAoTBElTUkcxKTAnBgNVBAMTIChT +VEFHSU5HKSBZZWFybmluZyBZdWNjYSBSb290IFlFMHYwEAYHKoZIzj0CAQYFK4EE +ACIDYgAE9LGxyXLRIUhNaCaufjztgLQYSwTDaqNhkD9nrf3IOkM1o5hqKUTGOm3S +uJVqzzLrRXT7JL+UCtshYPi5VSbyaH8JXgogE+HlzMPK/jQEsjYo91J9mEjnl1RQ +yhMyI3wlo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUkzsblChigfkfJ+uq35owDVquf+wwCgYIKoZIzj0EAwMDaAAwZQIwDlg5 +ZRknz/QOduNyg84Dy3oZwsMw0I0R180ZBzQ6YVNZZVbmyirNg3NJ3NP/kkS5AjEA +24b8Va6T6LmSXZqAJjnQjL71IZ20L6Nt7/g98v9hPxd+jq4hSuGvm85jKQNZdK65 +-----END CERTIFICATE----- diff --git a/backend/internal/certificate/trust/staging-yr.pem b/backend/internal/certificate/trust/staging-yr.pem new file mode 100644 index 0000000..e8e58a6 --- /dev/null +++ b/backend/internal/certificate/trust/staging-yr.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFUzCCAzugAwIBAgIRAOlpfXfxOryRKxxm6YaAvS0wDQYJKoZIhvcNAQELBQAw +QzELMAkGA1UEBhMCVVMxDTALBgNVBAoTBElTUkcxJTAjBgNVBAMTHChTVEFHSU5H +KSBZb25kZXIgWWFtIFJvb3QgWVIwHhcNMjUwOTAzMDAwMDAwWhcNNDUwOTAyMjM1 +OTU5WjBDMQswCQYDVQQGEwJVUzENMAsGA1UEChMESVNSRzElMCMGA1UEAxMcKFNU +QUdJTkcpIFlvbmRlciBZYW0gUm9vdCBZUjCCAiIwDQYJKoZIhvcNAQEBBQADggIP +ADCCAgoCggIBALae7+rQafYA6YhlSzozLNXkvQ4CLaYBVPUXicG/0soLOqvuhFAs +X4uagzipA5XYlTrZY7sABb/OttajOLpGOjpkQSAPYZwrrpyZSjRI0H1gK6sDDqKO +Yi2eKatFD+IbViZbpDqFaK0qLiv9xNV1k0d2cCJiKHwtPqdttN06Q204YYD80/2Y +7zFBNuKkEwXELSfMCCe58/5OgfzcjNvhphDDRRddQbW1GplRSVzEYvimXkaCv20E +wI3j6WP9AdQw3KoEHMAm97CDo+crBVCu6icnGvF5OWxfMBThcRhypkUT6zKW+7V3 +U1x5UspgwrVMffAgn8Ka/u/r5x4GqrFuWH69wBrfT+Ui/t4XJsMOI9F8egdxOJtB +PvIPZrSdS9ZOwmmqcIHkVtIIYnSiZjfuYGN6dhuz/B6uMlgvn5ictTlgoOhg8GZg +jELvwDFGsEVeNAFGGWWAQHqG2VP7inJm1ilF7mpNW2mVqe6Ko3LCAysWzJIOiho4 +S21XIR3GFLJQqC/gI0oXwAN5/FidHI1N6fgTJFo0ylbgHy2DfwwO+gr30Vl9Ng/q +4hs91bBVwsPZOZhRNM5vlYXPMSN01jYVOkJukdSq4AlNqjqYGifEZaJFCt0BVqyH +TgCEMDtJXnso03fzHr7MOtgc4NK20NZEt4fnlgXAbJDQhSSg7o87RngnAgMBAAGj +QjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRQ +sdeYAlBf2zJGG1jBaEByJG9KwTANBgkqhkiG9w0BAQsFAAOCAgEAb177bev0tqVD +JU1pu2Kgpn8007LXfcjfatJTZ4A8TFvgtkf2AyH9Qu3AMuqr9pMee7CRVMvA8Ahp +MhrWTHyzxPyFGlt8t/U+75zq9mE9IWNXMkWWrfph3wcNFDFkrEh6Hfhd1dd8UcqU +P6nJAuZYU69NVSQegthqeaZyA6owYDNmCcon4Fc4nudmfEyZsdTRXctbhy9QaXKn +dIyQ2nBWlyNLOgVLLMSHfuk3ksgNO5R68tfhevtR9nBSUORZHQc0Qc2+zBlE7tVh +SwtkyfhRa29zQy8XkcjG3EX6pLMgPkJIMKhour1mDudeJ3gkfAli9RMSUKdGRnP6 +/JmSPt1iTnQstG+L5abq1QVLymkxx+gLL5P7sixWo7pXmXnejafYxK8dYgAVYF/w +HV/EiIWcxkfUG8pU4SrC/z0Yr66C5uovtvpzvcxAjV4YYNLNJg2crbB9WiriBI4b +9/L1YTbwPvrxjcoQ8/XQZ1gQIIUJAi7w3ZecOdNWX+S35EiNpjUArCELRPAyYh4g +d3WeRS1woH9aIr7qEkBWW6IhbLOntrNvrguPprsvf5dlJ93EAVJ8fgOjJTBbl5/t +uhxa0IhOwAaSDM3Ep1J8IbzTMgzOofRtrWcTMmtKkaViidQFFCs8jqvB3eBiMnWQ +dlOhc6f6VwbPwd70DybV2JlWelXlLys= +-----END CERTIFICATE----- diff --git a/backend/internal/certificate/trust/来源说明.md b/backend/internal/certificate/trust/来源说明.md new file mode 100644 index 0000000..8ecc7c5 --- /dev/null +++ b/backend/internal/certificate/trust/来源说明.md @@ -0,0 +1,21 @@ +# ACME 测试根证书来源 + +获取日期:2026-09-15。 + +依据 Let's Encrypt 官方测试环境说明: + +- https://letsencrypt.org/docs/staging-environment/ +- https://github.com/letsencrypt/website/blob/main/content/en/docs/staging-environment.md + +公开根证书从官方 `letsencrypt/website` 仓库通过经过系统信任校验的 HTTPS 获取: + +| 本地文件 | 官方仓库路径(相对 `static/certs/staging/`) | +| --- | --- | +| `staging-x1.pem` | `letsencrypt-stg-root-x1.pem` | +| `staging-x2.pem` | `letsencrypt-stg-root-x2.pem` | +| `staging-ye.pem` | `gen-y/root-ye.pem` | +| `staging-yr.pem` | `gen-y/root-yr.pem` | + +这些文件只有公开证书,不含私钥。它们仅用于校验明确选择 `staging` 的 ACME 签发结果;不得导入操作系统或普通浏览器信任库,不用于 ACME HTTP 连接,也不用于 `production` 签发。不能将服务器返回的任意根或中间证书自动提升为信任锚。 + +升级根证书时须复核官方来源、证书自签名、有效期及回归测试;不得因测试链失败而关闭正式环境信任验证。 diff --git a/backend/internal/certificate/trust_test.go b/backend/internal/certificate/trust_test.go new file mode 100644 index 0000000..6634473 --- /dev/null +++ b/backend/internal/certificate/trust_test.go @@ -0,0 +1,71 @@ +package certificate + +import ( + "crypto/x509" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func testResourceRoots(t *testing.T, resource Resource) *x509.CertPool { + t.Helper() + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(resource.Certificate) { + t.Fatal("测试证书无法加入专用测试信任库") + } + return roots +} + +func TestACMETrustRootsAreEnvironmentIsolated(t *testing.T) { + staging, err := acmeTrustRoots("staging") + if err != nil || staging == nil { + t.Fatalf("测试环境根加载失败:%v", err) + } + production, err := acmeTrustRoots("production") + if err != nil || production != nil { + t.Fatal("正式环境必须使用系统信任库,不能继承测试根") + } + entries, err := stagingTrust.ReadDir("trust") + if err != nil || len(entries) != 4 { + t.Fatalf("必须包含四个官方测试根:%v", err) + } + for _, entry := range entries { + data, err := stagingTrust.ReadFile("trust/" + entry.Name()) + if err != nil { + t.Fatal(err) + } + certs, err := parseCertificates(data) + if err != nil || len(certs) != 1 { + t.Fatalf("测试根文件无效:%s", entry.Name()) + } + root := certs[0] + if !root.IsCA || !strings.Contains(root.Subject.CommonName, "(STAGING)") || root.CheckSignatureFrom(root) != nil { + t.Fatalf("测试根身份或自签名无效:%s", entry.Name()) + } + if err := verifyChain(certs, staging, time.Now()); err != nil { + t.Fatalf("官方测试根应受测试信任库信任:%v", err) + } + if err := verifyChain(certs, production, time.Now()); err == nil { + t.Fatal("官方测试根不能受正式信任库信任") + } + } + if _, err := acmeTrustRoots("unknown"); err == nil { + t.Fatal("未知环境必须失败关闭") + } +} + +func TestACMERejectsUntrustedLeafEvenWithoutSeparateIssuer(t *testing.T) { + service := NewService(config.Default(t.TempDir()), nil, nil, nil, nil) + resource := testResource(t, "example.com") + for _, environment := range []string{"production", "staging"} { + if err := service.validateACMEResource(t.Context(), resource, []string{"example.com"}, environment); err == nil { + t.Fatalf("%s 不得因 issuer 字段为空而接受任意自签证书", environment) + } + } + service.acmeRoots = testResourceRoots(t, resource) + if err := service.validateACMEResource(t.Context(), resource, []string{"example.com"}, "staging"); err != nil { + t.Fatalf("专用测试根验证失败:%v", err) + } +} diff --git a/backend/internal/certificate/validation.go b/backend/internal/certificate/validation.go index 5c76f71..0d6eb07 100644 --- a/backend/internal/certificate/validation.go +++ b/backend/internal/certificate/validation.go @@ -1,6 +1,7 @@ package certificate import ( + "bytes" "context" "crypto/sha256" "crypto/tls" @@ -52,9 +53,17 @@ func (v LocalValidator) ValidateFiles(ctx context.Context, certificatePath, full if err != nil { return ValidationResult{}, fmt.Errorf("读取私钥文件失败:%w", err) } - if _, err := tls.X509KeyPair(chainData, privateKeyData); err != nil { + pair, err := tls.X509KeyPair(certificateData, privateKeyData) + if err != nil { return ValidationResult{}, fmt.Errorf("证书与私钥不匹配:%w", err) } + chainPair, err := tls.X509KeyPair(chainData, privateKeyData) + if err != nil { + return ValidationResult{}, fmt.Errorf("证书与私钥不匹配:%w", err) + } + if !bytes.Equal(pair.Certificate[0], chainPair.Certificate[0]) { + return ValidationResult{}, errors.New("证书文件与完整证书链的叶子证书不一致") + } return v.validatePEM(ctx, chainData, expectedDomains) } diff --git a/backend/internal/certificate/validation_test.go b/backend/internal/certificate/validation_test.go index 1f16d89..9158891 100644 --- a/backend/internal/certificate/validation_test.go +++ b/backend/internal/certificate/validation_test.go @@ -11,6 +11,8 @@ import ( "math/big" "net" "net/http/httptest" + "os" + "path/filepath" "testing" "time" ) @@ -43,6 +45,20 @@ func TestLocalValidatorRejectsMissingSAN(t *testing.T) { } } +func TestLocalValidatorDoesNotIgnoreCertificateFile(t *testing.T) { + cert, key, _ := validationTestCertificate(t, []string{"example.com"}) + root := t.TempDir() + certPath, chainPath, keyPath := filepath.Join(root, "cert.pem"), filepath.Join(root, "chain.pem"), filepath.Join(root, "key.pem") + for name, data := range map[string][]byte{certPath: []byte("invalid-certificate"), chainPath: cert, keyPath: key} { + if err := os.WriteFile(name, data, 0o600); err != nil { + t.Fatal(err) + } + } + if _, err := (LocalValidator{}).ValidateFiles(t.Context(), certPath, chainPath, keyPath, []string{"example.com"}); err == nil { + t.Fatal("完整证书链有效不能掩盖单独证书文件无效") + } +} + func TestRemoteValidatorChecksPresentedCertificate(t *testing.T) { certificateData, privateKeyData, leaf := validationTestCertificate(t, []string{"localhost", "127.0.0.1"}) pair, err := tls.X509KeyPair(certificateData, privateKeyData) diff --git a/backend/internal/cli/init_certificate.go b/backend/internal/cli/init_certificate.go new file mode 100644 index 0000000..9b09058 --- /dev/null +++ b/backend/internal/cli/init_certificate.go @@ -0,0 +1,118 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" + "git.pcux.cn/xuans/Scert/backend/internal/firewall" + "git.pcux.cn/xuans/Scert/backend/internal/secretstore" + "git.pcux.cn/xuans/Scert/backend/internal/setup" +) + +type initCertificateIssuer func(context.Context, config.Config) (certificate.SavedResource, error) +type initCertificateRollback func(context.Context, certificate.SavedResource) error + +func issueInitCertificate(ctx context.Context, cfg config.Config) (certificate.SavedResource, error) { + store, err := secretstore.Open(cfg.Paths.Config) + if err != nil { + return certificate.SavedResource{}, fmt.Errorf("打开初始化凭据存储失败:%w", err) + } + service := certificate.NewService(cfg, nil, store, dnsprovider.NewLegoFactory(), certificate.NewAccountRepository(store)) + service.SetDNSAuthorizationChecker(dnsprovider.NewAliyunAuthorizationChecker()) + return service.IssueForSetup(ctx, certificate.SetupCertificateRequest{ + Domains: []string{cfg.Web.Hostname}, CertificateType: "single", + ProviderID: cfg.DNS.Provider, CredentialRef: cfg.DNS.CredentialRef, + AccountEmail: cfg.Admin.Email, Environment: cfg.ACME.Environment, + CertificateRoot: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup, + }) +} + +// completeInit 在证书校验及配置保存均成功后才发布就绪状态;失败时独立回滚证书。 +func completeInit(ctx context.Context, cfg *config.Config, path string, manager firewall.Manager, issue initCertificateIssuer, rollback initCertificateRollback) (result firewall.Result, err error) { + if cfg == nil || issue == nil || rollback == nil { + return result, errors.New("初始化证书事务未配置") + } + cfg.SetupReady, cfg.Web.InitReady = false, false + if cfg.Web.PublicIP != "" || strings.TrimSpace(cfg.Web.Hostname) == "" { + return result, errors.New("DNS-01 初始化必须配置管理后台域名,不支持直接签发公网 IP 证书") + } + if err := cfg.Validate(); err != nil { + return result, err + } + issueCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + saved, err := issue(issueCtx, *cfg) + cancel() + if err != nil { + return result, fmt.Errorf("申请管理后台证书失败,初始化尚未完成:%w", err) + } + defer func() { + if err == nil { + return + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if rollbackErr := rollback(rollbackCtx, saved); rollbackErr != nil { + err = errors.Join(err, fmt.Errorf("管理后台证书回滚失败,请手动检查证书目录:%w", rollbackErr)) + } + }() + for _, filename := range []string{saved.CertificatePath, saved.FullChainPath, saved.PrivateKeyPath} { + info, statErr := os.Lstat(filename) + if statErr != nil || !info.Mode().IsRegular() { + return result, fmt.Errorf("初始化证书必须是可读取的普通文件:%s", filename) + } + } + if _, err = (certificate.LocalValidator{}).ValidateFiles(ctx, saved.CertificatePath, saved.FullChainPath, saved.PrivateKeyPath, []string{cfg.Web.Hostname}); err != nil { + return result, fmt.Errorf("初始化证书文件校验失败:%w", err) + } + // 签发服务已按 ACME 环境验证信任链,这里再次校验实际落盘内容与私钥。 + updated := *cfg + updated.Server.TLSCert, updated.Server.TLSKey = saved.FullChainPath, saved.PrivateKeyPath + updated.SetupReady, updated.Web.InitReady = true, true + if err = updated.Validate(); err != nil { + return result, err + } + if err = ctx.Err(); err != nil { + return result, err + } + result, err = saveSetupWithFirewall(ctx, updated, path, manager) + if err == nil { + *cfg = updated + } + return result, err +} + +func verifyExistingDNSCredentials(ctx context.Context, cfg config.Config, input *setup.Input, output io.Writer, checker certificate.DNSAuthorizationChecker) (func(), error) { + input.DNSVerified = false + if !strings.EqualFold(input.DNSProvider, dnsprovider.Aliyun) || checker == nil { + return func() {}, errors.New("已有凭据引用必须通过阿里云在线权限校验,不能通过参数声明已验证") + } + target := strings.TrimSpace(input.WebHostname) + if target == "" { + return func() {}, errors.New("校验已有 DNS 凭据前必须填写管理后台域名") + } + store, err := secretstore.Open(cfg.Paths.Config) + if err != nil { + return func() {}, err + } + credentials, err := store.Get(ctx, input.DNSCredentialRef) + if err != nil { + return func() {}, fmt.Errorf("读取已有 DNS 凭据失败:%w", err) + } + verifyCtx, cancel := context.WithTimeout(ctx, dnsVerificationTimeout(cfg)) + defer cancel() + if err := checker.VerifyDomain(verifyCtx, input.DNSProvider, target, credentials); err != nil { + return func() {}, fmt.Errorf("已有 DNS 凭据在线权限校验失败:%w", err) + } + input.DNSVerified = true + fmt.Fprintln(output, "已有 DNS 凭据已重新通过管理后台域名的在线权限校验。") + // 用户原有凭据不属于本次事务,即使后续失败也不得删除。 + return func() {}, nil +} diff --git a/backend/internal/cli/init_certificate_test.go b/backend/internal/cli/init_certificate_test.go new file mode 100644 index 0000000..ada1054 --- /dev/null +++ b/backend/internal/cli/init_certificate_test.go @@ -0,0 +1,184 @@ +package cli + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "io" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/firewall" + "git.pcux.cn/xuans/Scert/backend/internal/secretstore" + "git.pcux.cn/xuans/Scert/backend/internal/setup" +) + +func initTestConfig(t *testing.T) config.Config { + t.Helper() + root := t.TempDir() + cfg := config.Default(root) + cfg.Paths.Config = filepath.Join(root, "conf") + cfg.Paths.Certificate = filepath.Join(root, "certificates") + cfg.Paths.Backup = filepath.Join(root, "backups") + cfg.Admin.Username, cfg.Admin.Email, cfg.Admin.PasswordHash = "admin", "admin@example.com", "test-only-hash" + cfg.Web.Hostname, cfg.Web.PublicIP = "panel.example.com", "" + cfg.DNS.Provider, cfg.DNS.CredentialRef, cfg.DNS.VerificationState = "aliyun", "secret://dns/aliyun/init-test", "verified" + return cfg +} + +func initTestResource(t *testing.T) certificate.Resource { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), DNSNames: []string{"panel.example.com"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + private, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + return certificate.Resource{ + Domains: []string{"panel.example.com"}, Certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: private}), + } +} + +func TestCompleteInitCommitsOnlyAfterCertificateAndRollsBackFailures(t *testing.T) { + for _, scenario := range []string{"success", "issue_failure", "invalid_files", "firewall_failure", "save_failure", "cancelled", "rollback_failure"} { + t.Run(scenario, func(t *testing.T) { + cfg := initTestConfig(t) + path := filepath.Join(cfg.Paths.Config, "scert.json") + manager := &cliFakeFirewallManager{result: firewall.Result{Created: true}} + if scenario == "firewall_failure" { + manager.openErr = errors.New("测试防火墙拒绝") + } + if scenario == "save_failure" { + blocked := filepath.Join(t.TempDir(), "blocked") + if err := os.WriteFile(blocked, []byte("blocked"), 0o600); err != nil { + t.Fatal(err) + } + path = filepath.Join(blocked, "scert.json") + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var saved certificate.SavedResource + issue := func(ctx context.Context, candidate config.Config) (certificate.SavedResource, error) { + if candidate.SetupReady || candidate.Web.InitReady { + t.Fatal("签发开始前不得标记就绪") + } + if scenario == "issue_failure" { + return saved, errors.New("测试签发失败") + } + store := certificate.FileStore{Root: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup} + var err error + saved, err = store.SaveForType(ctx, cfg.Web.Hostname, "single", initTestResource(t)) + if err != nil { + t.Fatal(err) + } + if scenario == "invalid_files" || scenario == "rollback_failure" { + if err := os.WriteFile(saved.PrivateKeyPath, []byte("invalid-test-key"), 0o600); err != nil { + t.Fatal(err) + } + } + if scenario == "cancelled" { + cancel() + } + return saved, nil + } + rolledBack := false + rollback := func(ctx context.Context, saved certificate.SavedResource) error { + rolledBack = true + if ctx.Err() != nil { + t.Fatal("回滚必须使用独立的有效上下文") + } + if err := certificate.RollbackSavedResource(ctx, saved); err != nil { + return err + } + if scenario == "rollback_failure" { + return errors.New("测试回滚报告失败") + } + return nil + } + _, err := completeInit(ctx, &cfg, path, manager, issue, rollback) + if scenario == "success" { + if err != nil || rolledBack || !cfg.SetupReady || !cfg.Web.InitReady { + t.Fatalf("正常初始化未提交就绪状态:%v", err) + } + stored, err := config.Load(path) + if err != nil || !stored.SetupReady || stored.Server.TLSCert != saved.FullChainPath || stored.Server.TLSKey != saved.PrivateKeyPath { + t.Fatalf("证书配置未正确保存:%v", err) + } + return + } + if err == nil || cfg.SetupReady || cfg.Web.InitReady { + t.Fatalf("失败时不得发布就绪状态:%v", err) + } + if _, err := os.Stat(path); err == nil { + t.Fatal("失败时不得留下已完成的配置") + } + if scenario != "issue_failure" && !rolledBack { + t.Fatal("后续阶段失败必须回滚证书") + } + if scenario == "save_failure" && !manager.rollbackCall { + t.Fatal("保存失败必须回滚新建防火墙规则") + } + if scenario == "rollback_failure" && !strings.Contains(err.Error(), "手动检查") { + t.Fatal("必须明确报告回滚失败") + } + }) + } +} + +type initAuthorizationChecker struct { + err error + calls int +} + +func (c *initAuthorizationChecker) VerifyDomain(context.Context, string, string, map[string]string) error { + c.calls++ + return c.err +} + +func TestExistingDNSReferenceCannotBypassVerification(t *testing.T) { + cfg := initTestConfig(t) + store, err := secretstore.Open(cfg.Paths.Config) + if err != nil { + t.Fatal(err) + } + if err := store.Put(t.Context(), cfg.DNS.CredentialRef, map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}); err != nil { + t.Fatal(err) + } + for _, denied := range []bool{false, true} { + input := &setup.Input{DNSProvider: "aliyun", DNSCredentialRef: cfg.DNS.CredentialRef, DNSVerified: true, WebHostname: cfg.Web.Hostname} + checker := &initAuthorizationChecker{} + if denied { + checker.err = errors.New("测试撤权") + } + cleanup, err := verifyExistingDNSCredentials(t.Context(), cfg, input, io.Discard, checker) + cleanup() + if checker.calls != 1 || (err != nil) != denied || input.DNSVerified == denied { + t.Fatal("已有引用或已验证参数不得绕过当前权限检查") + } + if exists, err := store.Has(t.Context(), cfg.DNS.CredentialRef); err != nil || !exists { + t.Fatal("失败清理不得删除用户原有凭据") + } + } +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index a3a83cd..b11b434 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -116,13 +116,17 @@ func newInitCommand() *cobra.Command { } func newInitCommandWithFirewall(firewallManager firewall.Manager) *cobra.Command { + return newInitCommandWithDependencies(firewallManager, issueInitCertificate) +} + +func newInitCommandWithDependencies(firewallManager firewall.Manager, issue initCertificateIssuer) *cobra.Command { var ( adminUsername, adminEmail, adminPassword, emailProvider, emailEndpoint string emailHTTPPath, emailHTTPLoginPath, emailHTTPAuthMode, emailHTTPCredentialRef, emailHTTPHeaders string emailSMTPHost, emailSMTPUsername, emailSMTPPassword, emailSMTPMode string emailEventMode, emailSenderName, emailSenderEmail, emailRetryIntervals string recipients, dnsProvider, dnsCredentialRef, certificateRoot string - webHostname, webPublicIP, httpsMode string + webHostname, webPublicIP, httpsMode, acmeEnvironment string serverPort, emailTimeoutSecs, emailRetryCount, emailSMTPPort int emailHTTPEnabled, emailSMTPEnabled, emailSMTPAllowPlaintextAuth, emailIncludeDeploymentDetails bool emailIncludeCertificateSerial, emailIncludeManagementURL bool @@ -136,6 +140,18 @@ func newInitCommandWithFirewall(firewallManager firewall.Manager) *cobra.Command return err } cfg := config.Default(baseDir) + cfg.ACME.Environment = acmeEnvironment + lock, err := acquireProcessLock(cfg, "初始化、服务或备份恢复任务正在执行,请稍后重试") + if err != nil { + return err + } + defer lock.Close() + // 等待进程锁期间另一个初始化可能已完成,必须在取得锁后再次检查。 + if _, err := os.Lstat(path); err == nil { + return fmt.Errorf("配置文件已存在:%s", path) + } else if !errors.Is(err, os.ErrNotExist) { + return err + } emailHeaders, err := parseStringMap(emailHTTPHeaders) if err != nil { return fmt.Errorf("HTTP 自定义请求头格式无效:%w", err) @@ -184,13 +200,16 @@ func newInitCommandWithFirewall(firewallManager firewall.Manager) *cobra.Command cleanupSecret() return err } - firewallResult, err := saveSetupWithFirewall(cmd.Context(), cfg, path, firewallManager) + firewallResult, err := completeInit(cmd.Context(), &cfg, path, firewallManager, issue, certificate.RollbackSavedResource) if err != nil { cleanupSecret() return err } fmt.Fprintf(cmd.OutOrStdout(), "初始化配置已保存:%s\n", path) - fmt.Fprintf(cmd.OutOrStdout(), "DNS API 已完成在线验证,凭据已加密保存;真实 Web 证书申请将在后续任务接入。\n") + fmt.Fprintln(cmd.OutOrStdout(), "管理后台证书已申请并校验,DNS 凭据已加密保存。") + if cfg.ACME.Environment == "staging" { + fmt.Fprintln(cmd.OutOrStdout(), "当前使用 ACME 测试环境,证书不受浏览器信任,不能作为正式生产证书。") + } fmt.Fprintf(cmd.OutOrStdout(), "本机防火墙:%s\n", firewallResult.Message) fmt.Fprintf(cmd.OutOrStdout(), "请确认云安全组已放行主服务端口 %d。Scert-admin 默认关闭;需要重置密码时启动后访问:%s\n", cfg.Server.Port, cfg.ResetURL()) return nil @@ -226,7 +245,8 @@ func newInitCommandWithFirewall(firewallManager firewall.Manager) *cobra.Command command.Flags().StringVar(&emailRetryIntervals, "email-retry-intervals", "30,120,600", "通知重试间隔(秒,逗号分隔)") command.Flags().StringVar(&dnsProvider, "dns-provider", "", "DNS API 服务商标识") command.Flags().StringVar(&dnsCredentialRef, "dns-credential-ref", "", "DNS 凭据引用") - command.Flags().BoolVar(&dnsVerified, "dns-verified", false, "DNS API 是否已通过验证测试") + command.Flags().BoolVar(&dnsVerified, "dns-verified", false, "兼容参数,不会跳过在线权限和证书校验") + command.Flags().StringVar(&acmeEnvironment, "acme-environment", "staging", "ACME 环境:staging 为测试,production 为正式签发") command.Flags().StringVar(&certificateRoot, "certificate-root", "", "证书部署根目录,默认使用系统目录") command.Flags().StringVar(&webHostname, "web-hostname", "", "管理页面域名") command.Flags().StringVar(&webPublicIP, "web-public-ip", "", "没有域名时使用的公网 IP") @@ -442,7 +462,7 @@ func prepareDNSCredentials(ctx context.Context, cfg config.Config, input *setup. return func() {}, errors.New("初始化输入不能为空") } if strings.TrimSpace(input.DNSCredentialRef) != "" { - return func() {}, nil + return verifyExistingDNSCredentials(ctx, cfg, input, output, dnsprovider.NewAliyunAuthorizationChecker()) } if len(input.DNSCredentials) == 0 { return func() {}, errors.New("DNS 凭据不能为空") diff --git a/backend/internal/deployment/client.go b/backend/internal/deployment/client.go index a92496d..8a51c6e 100644 --- a/backend/internal/deployment/client.go +++ b/backend/internal/deployment/client.go @@ -8,6 +8,8 @@ type CommandResult struct { } type Client interface { + Lock(ctx context.Context, path string) error + Unlock(ctx context.Context, path string) error MkdirAll(ctx context.Context, path string) error Exists(ctx context.Context, path string) (bool, error) Upload(ctx context.Context, path string, data []byte, mode uint32) error diff --git a/backend/internal/deployment/remote_acceptance_test.go b/backend/internal/deployment/remote_acceptance_test.go index 9c443c2..2ac58a0 100644 --- a/backend/internal/deployment/remote_acceptance_test.go +++ b/backend/internal/deployment/remote_acceptance_test.go @@ -113,6 +113,9 @@ func TestRemoteDeploymentIsolatedAcceptance(t *testing.T) { if strings.Contains(file.Name(), ".scert-tmp-") { t.Fatalf("残留临时文件:%s", file.Name()) } + if strings.HasSuffix(file.Name(), ".scert-lock") { + t.Fatalf("残留部署锁:%s", file.Name()) + } if strings.HasPrefix(file.Name(), "key.pem") && file.Mode().Perm() != 0o600 { t.Fatal("私钥或私钥备份权限不安全") } diff --git a/backend/internal/deployment/service.go b/backend/internal/deployment/service.go index 5f12283..db7b998 100644 --- a/backend/internal/deployment/service.go +++ b/backend/internal/deployment/service.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "sort" "strings" "time" ) @@ -108,11 +109,11 @@ func (s *Service) DeploySaved(ctx context.Context, targets []Target, saved Saved return s.Deploy(ctx, targets, Artifact{Certificate: certificateData, FullChain: fullChainData, PrivateKey: privateKeyData}), nil } -func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifact) Result { +func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifact) (result Result) { ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() server = server.Normalize() - result := Result{ServerID: server.ID, Server: server.Name, Status: "failed"} + result = Result{ServerID: server.ID, Server: server.Name, Status: "failed"} if err := server.Validate(); err != nil { result.Message = err.Error() return result @@ -138,6 +139,43 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac result.Message = err.Error() return result } + // 每个实际文件路径使用远程协作锁,阻止不同任务或进程的替换与回滚相互覆盖。 + lockPaths := make([]string, 0, len(paths)) + for _, file := range paths { + if err := client.MkdirAll(ctx, parentPath(file.path)); err != nil { + result.Message = fmt.Sprintf("创建远程目录失败:%v", err) + return result + } + lockPaths = append(lockPaths, file.path+".scert-lock") + } + sort.Strings(lockPaths) + heldLocks := make([]string, 0, len(lockPaths)) + retainLocks := false + defer func() { + if len(heldLocks) == 0 || retainLocks { + return + } + releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), deploymentRollbackTimeout) + defer cancel() + if err := reconnectDeploymentClient(releaseCtx, s.factory, server, &client); err != nil { + result.Status = "failed" + result.Message += fmt.Sprintf(";释放部署锁失败,需人工检查:%v", err) + return + } + for index := len(heldLocks) - 1; index >= 0; index-- { + if err := client.Unlock(releaseCtx, heldLocks[index]); err != nil { + result.Status = "failed" + result.Message += fmt.Sprintf(";释放部署锁 %s 失败,需人工检查:%v", heldLocks[index], err) + } + } + }() + for _, lockPath := range lockPaths { + if err := client.Lock(ctx, lockPath); err != nil { + result.Message = fmt.Sprintf("无法取得部署锁 %s,可能存在并发部署或遗留锁;未修改当前证书,请人工核查:%v", lockPath, err) + return result + } + heldLocks = append(heldLocks, lockPath) + } backupID := s.backupID() result.BackupID = backupID type backup struct{ original, saved string } @@ -147,13 +185,8 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac rollback := func() error { rollbackContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), deploymentRollbackTimeout) defer cancel() - if state, ok := client.(interface{ Disconnected() bool }); ok && state.Disconnected() { - _ = client.Close() - reconnected, err := s.factory.Connect(rollbackContext, server) - if err != nil { - return fmt.Errorf("重新连接服务器以回滚失败:%w", err) - } - client = reconnected + if err := reconnectDeploymentClient(rollbackContext, s.factory, server, &client); err != nil { + return fmt.Errorf("重新连接服务器以回滚失败:%w", err) } var rollbackErrors []error for _, path := range temporary { @@ -187,6 +220,8 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac failed := func(message string) Result { if rollbackErr := rollback(); rollbackErr != nil { message += fmt.Sprintf(";自动回滚失败:%v", rollbackErr) + retainLocks = true + message += ";已保留本次部署锁,修复并确认文件状态后再由管理员释放" } else { message += ";文件已回滚" } @@ -198,13 +233,15 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac return result } for _, file := range paths { - if err := client.MkdirAll(ctx, parentPath(file.path)); err != nil { - return failed(fmt.Sprintf("创建远程目录失败:%v", err)) - } temporaryPath := file.path + ".scert-tmp-" + backupID // 上传失败也可能留下半份私钥,必须在开始上传前登记清理路径。 temporary = append(temporary, temporaryPath) if err := client.Upload(ctx, temporaryPath, file.data, file.mode); err != nil { + var notCreated *uploadNotCreatedError + if errors.As(err, ¬Created) { + temporary = temporary[:len(temporary)-1] + return failed(fmt.Sprintf("上传失败且未确认临时路径所有权,已保留 %s 供人工核查:%v", temporaryPath, err)) + } return failed(fmt.Sprintf("上传远程证书失败:%v", err)) } } @@ -216,6 +253,9 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac } if exists { backupPath := file.path + ".scert-backup-" + backupID + if exists, err := client.Exists(ctx, backupPath); err != nil || exists { + return failed(fmt.Sprintf("备份目标已存在或无法检查,已停止替换:%s:%v", backupPath, err)) + } backups = append(backups, backup{original: file.path, saved: backupPath}) if err := client.Rename(ctx, file.path, backupPath); err != nil { return failed(fmt.Sprintf("备份远程证书失败:%v", err)) @@ -248,6 +288,18 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac return result } +func reconnectDeploymentClient(ctx context.Context, factory ClientFactory, server Server, client *Client) error { + if state, ok := (*client).(interface{ Disconnected() bool }); ok && state.Disconnected() { + _ = (*client).Close() + reconnected, err := factory.Connect(ctx, server) + if err != nil { + return err + } + *client = reconnected + } + return nil +} + type remoteFile struct { path string data []byte diff --git a/backend/internal/deployment/service_test.go b/backend/internal/deployment/service_test.go index 9a819d7..756f523 100644 --- a/backend/internal/deployment/service_test.go +++ b/backend/internal/deployment/service_test.go @@ -38,6 +38,19 @@ func newMemoryClient() *memoryClient { func (c *memoryClient) MkdirAll(context.Context, string) error { return nil } +func (c *memoryClient) Lock(_ context.Context, path string) error { + if _, exists := c.files[path]; exists { + return errors.New("模拟锁已存在") + } + c.files[path] = memoryFile{} + return nil +} + +func (c *memoryClient) Unlock(_ context.Context, path string) error { + delete(c.files, path) + return nil +} + func (c *memoryClient) Exists(_ context.Context, path string) (bool, error) { _, ok := c.files[path] return ok, nil @@ -197,6 +210,11 @@ func TestDeployReportsRollbackFailure(t *testing.T) { if !strings.Contains(report.Results[0].Message, "自动回滚失败") || !strings.Contains(report.Results[0].Message, "恢复备份文件") { t.Fatalf("应报告自动回滚失败原因:%s", report.Results[0].Message) } + for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} { + if _, exists := client.files[path+".scert-lock"]; !exists { + t.Fatal("回滚失败后必须保留协作锁,阻止后续任务覆盖待恢复现场") + } + } } func TestDeployReportsPartialSuccess(t *testing.T) { diff --git a/backend/internal/deployment/ssh.go b/backend/internal/deployment/ssh.go index a682c2c..16d7c50 100644 --- a/backend/internal/deployment/ssh.go +++ b/backend/internal/deployment/ssh.go @@ -213,6 +213,11 @@ func sshOperation[T any](ctx context.Context, c *sshClientAdapter, operation fun _ = c.ssh.Close() return zero, err } + var networkErr *net.OpError + if errors.Is(result.err, io.EOF) || errors.Is(result.err, io.ErrUnexpectedEOF) || errors.Is(result.err, net.ErrClosed) || errors.As(result.err, &networkErr) { + c.disconnected.Store(true) + _ = c.ssh.Close() + } return result.value, result.err case <-ctx.Done(): c.disconnected.Store(true) @@ -233,6 +238,15 @@ func (c *sshClientAdapter) MkdirAll(ctx context.Context, path string) error { return c.fileOperation(ctx, func() error { return c.sftp.MkdirAll(path) }) } +// 远程原子建目录用于协作锁,跨进程共享;已有锁绝不自动抢占或清理。 +func (c *sshClientAdapter) Lock(ctx context.Context, path string) error { + return c.fileOperation(ctx, func() error { return c.sftp.Mkdir(path) }) +} + +func (c *sshClientAdapter) Unlock(ctx context.Context, path string) error { + return c.fileOperation(ctx, func() error { return c.sftp.RemoveDirectory(path) }) +} + func (c *sshClientAdapter) Exists(ctx context.Context, path string) (bool, error) { return sshOperation(ctx, c, func() (bool, error) { _, err := c.sftp.Stat(path) @@ -244,12 +258,14 @@ func (c *sshClientAdapter) Exists(ctx context.Context, path string) (bool, error } func (c *sshClientAdapter) Upload(ctx context.Context, path string, data []byte, mode uint32) error { - return c.fileOperation(ctx, func() error { + var created atomic.Bool + err := c.fileOperation(ctx, func() error { // 只创建新临时文件,不跟随已有符号链接,也不截断已存在的文件。 file, err := c.sftp.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL) if err != nil { return err } + created.Store(true) // 先收紧空文件权限,再写入任何证书或私钥内容。 if err := file.Chmod(os.FileMode(mode)); err != nil { _ = file.Close() @@ -261,8 +277,18 @@ func (c *sshClientAdapter) Upload(ctx context.Context, path string, data []byte, } return file.Close() }) + if err != nil && !created.Load() { + // 未确认取得所有权的路径不得交给事务删除,包括创建响应丢失的情况。 + return &uploadNotCreatedError{err: err} + } + return err } +type uploadNotCreatedError struct{ err error } + +func (e *uploadNotCreatedError) Error() string { return e.err.Error() } +func (e *uploadNotCreatedError) Unwrap() error { return e.err } + func (c *sshClientAdapter) Rename(ctx context.Context, source, target string) error { return c.fileOperation(ctx, func() error { return c.sftp.Rename(source, target) }) } diff --git a/backend/internal/deployment/ssh_safety_test.go b/backend/internal/deployment/ssh_safety_test.go index eb3841c..8224a19 100644 --- a/backend/internal/deployment/ssh_safety_test.go +++ b/backend/internal/deployment/ssh_safety_test.go @@ -152,6 +152,36 @@ func TestSSHFactoryCancelsUnresponsiveSFTPInitialization(t *testing.T) { } } +func TestSSHDeploymentLockIsSharedAcrossConnections(t *testing.T) { + factory, server := safetyFactory(t, requestSFTPHandler(sftp.InMemHandler(), false, false)) + first, err := factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + defer second.Close() + const path = "/certificate.scert-lock" + if err := first.Lock(t.Context(), path); err != nil { + t.Fatal(err) + } + if err := second.Lock(t.Context(), path); err == nil { + t.Fatal("其他 SSH 连接不得取得同一个远程部署锁") + } + if err := first.Unlock(t.Context(), path); err != nil { + t.Fatal(err) + } + if err := second.Lock(t.Context(), path); err != nil { + t.Fatalf("释放锁后应允许后续部署:%v", err) + } + if err := second.Unlock(t.Context(), path); err != nil { + t.Fatal(err) + } +} + type stalledLister struct{ release <-chan struct{} } func (l stalledLister) Filelist(*sftp.Request) (sftp.ListerAt, error) { diff --git a/backend/internal/deployment/transaction_test.go b/backend/internal/deployment/transaction_test.go new file mode 100644 index 0000000..5152514 --- /dev/null +++ b/backend/internal/deployment/transaction_test.go @@ -0,0 +1,110 @@ +package deployment + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestDeployLeavesExistingLockAndCertificateUntouched(t *testing.T) { + client := newMemoryClient() + server := testServer("locked") + lockPath := server.PrivateKeyPath + ".scert-lock" + client.files[lockPath] = memoryFile{data: []byte("other-transaction")} + client.files[server.CertificatePath] = memoryFile{data: []byte("current-certificate")} + service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) + report := service.Deploy(t.Context(), []Target{{Server: server}}, testArtifact(t)) + if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "部署锁") { + t.Fatalf("已有部署锁时必须停止:%+v", report) + } + if len(client.files) != 2 || string(client.files[lockPath].data) != "other-transaction" || string(client.files[server.CertificatePath].data) != "current-certificate" || len(client.commands) != 0 { + t.Fatal("锁冲突时不得覆盖证书、移除他人的锁或遗留自己取得的锁") + } +} + +type collisionClient struct { + *memoryClient + collided string +} + +func (c *collisionClient) Upload(_ context.Context, path string, _ []byte, _ uint32) error { + c.collided = path + c.files[path] = memoryFile{data: []byte("not-owned")} + return &uploadNotCreatedError{err: errors.New("测试独占创建冲突")} +} + +type fixedClientFactory struct{ client Client } + +func (f fixedClientFactory) Connect(context.Context, Server) (Client, error) { return f.client, nil } + +func TestDeployDoesNotRemoveUnownedUploadPath(t *testing.T) { + client := &collisionClient{memoryClient: newMemoryClient()} + server := testServer("collision") + client.files[server.CertificatePath] = memoryFile{data: []byte("current")} + report := NewService(fixedClientFactory{client}).Deploy(t.Context(), []Target{{Server: server}}, testArtifact(t)) + if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "所有权") { + t.Fatalf("必须报告所有权不明确:%+v", report) + } + if string(client.files[client.collided].data) != "not-owned" || string(client.files[server.CertificatePath].data) != "current" || len(client.files) != 2 { + t.Fatal("上传冲突不得删除原有临时文件或修改当前证书") + } +} + +type lostRenameClient struct { + *memoryClient + lostBackup bool + lost bool + closed bool +} + +func (c *lostRenameClient) Disconnected() bool { return c.closed } + +func (c *lostRenameClient) Rename(ctx context.Context, source, target string) error { + if err := c.memoryClient.Rename(ctx, source, target); err != nil { + return err + } + isBackup := strings.Contains(target, ".scert-backup-") + isInstall := strings.Contains(source, ".scert-tmp-") + if !c.lost && ((c.lostBackup && isBackup) || (!c.lostBackup && isInstall)) { + c.lost, c.closed = true, true + return context.DeadlineExceeded + } + return nil +} + +type reconnectingMemoryFactory struct { + client *lostRenameClient + calls int +} + +func (f *reconnectingMemoryFactory) Connect(context.Context, Server) (Client, error) { + f.calls++ + f.client.closed = false + return f.client, nil +} + +func TestDeployRecoversWhenRenameSucceededButReplyWasLost(t *testing.T) { + for _, backup := range []bool{true, false} { + t.Run(map[bool]string{true: "backup", false: "install"}[backup], func(t *testing.T) { + client := &lostRenameClient{memoryClient: newMemoryClient(), lostBackup: backup} + server := testServer("lost-reply") + for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} { + client.files[path] = memoryFile{data: []byte("original"), mode: 0o600} + } + factory := &reconnectingMemoryFactory{client: client} + report := NewService(factory).Deploy(t.Context(), []Target{{Server: server}}, testArtifact(t)) + if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "文件已回滚") || factory.calls != 2 { + t.Fatalf("响应丢失后应重连并回滚:%+v,连接次数 %d", report, factory.calls) + } + if len(client.files) != 3 || len(client.commands) != 0 { + t.Fatal("回滚后不得残留临时文件或锁,也不得执行 reload") + } + for _, file := range client.files { + if string(file.data) != "original" || file.mode != 0o600 { + t.Fatal("响应丢失后原始文件内容或权限被破坏") + } + } + }) + } +} diff --git a/backend/internal/httpapi/setup.go b/backend/internal/httpapi/setup.go index 5d66381..ca77e72 100644 --- a/backend/internal/httpapi/setup.go +++ b/backend/internal/httpapi/setup.go @@ -427,6 +427,7 @@ func (s *Server) applySetup(c *gin.Context) { } } updated.SetupReady = true + updated.Web.InitReady = true if err := updated.Validate(); err != nil { s.rollbackSetupCredentialsWithLog(createdCredentialRefs) if rollbackErr := s.rollbackIssuedSetupCertificate(savedCertificate); rollbackErr != nil && s.logger != nil { diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 8f2dc94..9872693 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -120,7 +120,8 @@ func Apply(cfg *config.Config, input Input) error { cfg.Web.Enabled = input.WebEnabled cfg.Web.Hostname = strings.TrimSpace(input.WebHostname) cfg.Web.PublicIP = strings.TrimSpace(input.WebPublicIP) - cfg.Web.InitReady = true + // 配置输入通过不等于初始化完成;调用方必须先取得并校验管理后台证书。 + cfg.Web.InitReady = false cfg.Paths.Certificate = strings.TrimSpace(input.CertificateRoot) cfg.DNS.Provider = strings.TrimSpace(input.DNSProvider) cfg.DNS.CredentialRef = strings.TrimSpace(input.DNSCredentialRef) @@ -179,7 +180,7 @@ func Apply(cfg *config.Config, input Input) error { if len(cfg.Notification.Recipients) == 0 { cfg.Notification.Recipients = []string{adminEmail} } - cfg.SetupReady = true + cfg.SetupReady = false if cfg.Notification.SMTPEnabled && strings.TrimSpace(cfg.Notification.SMTPCredentialRef) == "" { return fmt.Errorf("启用 SMTP 通知后必须配置加密凭据引用") } diff --git a/backend/internal/setup/setup_test.go b/backend/internal/setup/setup_test.go index d7c0e85..d4af884 100644 --- a/backend/internal/setup/setup_test.go +++ b/backend/internal/setup/setup_test.go @@ -21,7 +21,7 @@ func TestApplyRequiresVerifiedDNS(t *testing.T) { } } -func TestApplyBuildsReadyConfig(t *testing.T) { +func TestApplyBuildsPendingConfig(t *testing.T) { cfg := config.Default(t.TempDir()) err := Apply(&cfg, Input{ AdminUsername: "admin", AdminEmail: "admin@example.com", AdminPassword: "password123", ServerPort: 8080, @@ -33,7 +33,7 @@ func TestApplyBuildsReadyConfig(t *testing.T) { if err != nil { t.Fatalf("初始化配置失败:%v", err) } - if !cfg.SetupReady || cfg.DNS.VerificationState != "verified" || cfg.Admin.Username != "admin" { + if cfg.SetupReady || cfg.Web.InitReady || cfg.DNS.VerificationState != "verified" || cfg.Admin.Username != "admin" { t.Fatalf("初始化状态不正确:%+v", cfg) } if len(cfg.Notification.Recipients) != 2 { diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..eabe64a --- /dev/null +++ b/build/README.md @@ -0,0 +1,47 @@ +# Scert 发布构建 + +`build-release.ps1` 默认构建 Windows amd64 和 Linux amd64 的核心服务与密码重置程序。当前主机能够直接构建的 Wails 桌面版也会同时写入对应平台目录,并记录到 `manifest.json`。 + +```text +build/release/ +├── manifest.json +├── windows-amd64/ +│ ├── scert.exe +│ ├── scert-admin.exe +│ └── ScertDesktop.exe +└── linux-amd64/ + ├── scert + └── scert-admin +``` + +## 使用方法 + +在项目根目录执行: + +```powershell +powershell -ExecutionPolicy Bypass -File .\build\build-release.ps1 +``` + +只构建 Windows 核心服务和桌面版: + +```powershell +powershell -ExecutionPolicy Bypass -File .\build\build-release.ps1 -Target windows-amd64 +``` + +只构建核心服务,不构建桌面版: + +```powershell +powershell -ExecutionPolicy Bypass -File .\build\build-release.ps1 -SkipDesktop +``` + +指定输出目录: + +```powershell +powershell -ExecutionPolicy Bypass -File .\build\build-release.ps1 -OutputDirectory D:\release\Scert +``` + +桌面版构建需要已安装 Wails v2、Go 和 Node.js。Wails 桌面版使用当前主机原生平台构建;Windows 主机不能直接完成 Linux GTK 桌面交叉构建,Linux 桌面产物需要在 Linux 主机上执行同一脚本。 + +脚本不会删除输出目录中的其他文件,只会创建目录并覆盖本次目标平台对应的程序文件和校验清单。 + +构建通过不等于生产验收通过;真实 DNS、ACME、SSH、Windows Service、systemd、WebView2 和 HTTPS 仍需在授权环境复核。 diff --git a/build/build-release.ps1 b/build/build-release.ps1 new file mode 100644 index 0000000..1f29422 --- /dev/null +++ b/build/build-release.ps1 @@ -0,0 +1,389 @@ +[CmdletBinding()] +param( + [string]$BackendDirectory = "", + [string]$OutputDirectory = "", + [string]$DesktopDirectory = "", + [string]$DeployDirectory = "", + [ValidatePattern("^V[0-9]+\.[0-9]+(?:\.[0-9]+)?$")] + [string]$Version = "V0.1.0", + [ValidatePattern("^[0-9]{8}$")] + [string]$ReleaseDate = (Get-Date -Format "yyyyMMdd"), + [ValidateSet("windows-amd64", "linux-amd64")] + [string[]]$Target = @("windows-amd64", "linux-amd64"), + [switch]$SkipDesktop +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Definition +if ([string]::IsNullOrWhiteSpace($BackendDirectory)) { + $BackendDirectory = Join-Path $scriptDirectory "..\backend" +} +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $scriptDirectory "release" +} +if ([string]::IsNullOrWhiteSpace($DesktopDirectory)) { + $DesktopDirectory = Join-Path $scriptDirectory "..\desktop" +} +if ([string]::IsNullOrWhiteSpace($DeployDirectory)) { + $DeployDirectory = Join-Path $scriptDirectory "..\deploy" +} + +function Resolve-SafeDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Label + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "$Label 不能为空。" + } + return [System.IO.Path]::GetFullPath($Path) +} + +function Invoke-GoBuild { + param( + [Parameter(Mandatory = $true)] + [string]$OperatingSystem, + [Parameter(Mandatory = $true)] + [string]$Architecture, + [Parameter(Mandatory = $true)] + [string]$Destination, + [Parameter(Mandatory = $true)] + [string]$Package + ) + + $env:GOOS = $OperatingSystem + $env:GOARCH = $Architecture + $env:CGO_ENABLED = "0" + & go build -trimpath -o $Destination $Package + if ($LASTEXITCODE -ne 0) { + throw "构建 $OperatingSystem/$Architecture 的 $Package 失败。" + } +} + +function Get-HostDesktopTarget { + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Windows + )) { + return "windows-amd64" + } + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Linux + )) { + return "linux-amd64" + } + return "" +} + +function Invoke-DesktopBuild { + param( + [Parameter(Mandatory = $true)] + [string]$ProjectDirectory, + [Parameter(Mandatory = $true)] + [string]$TargetName, + [Parameter(Mandatory = $true)] + [string]$Destination + ) + + if (-not (Get-Command wails -ErrorAction SilentlyContinue)) { + throw "未找到 Wails CLI,请先安装 Wails v2." + } + if (-not (Test-Path -LiteralPath (Join-Path $ProjectDirectory "wails.json") -PathType Leaf)) { + throw "桌面项目中没有找到 wails.json:$ProjectDirectory" + } + + Write-Host "正在构建 $TargetName 的 Scert 桌面版……" + $desktopGoOS = $env:GOOS + $desktopGoArch = $env:GOARCH + $desktopCGOEnabled = $env:CGO_ENABLED + Push-Location $ProjectDirectory + try { + $env:GOOS = "" + $env:GOARCH = "" + if ($TargetName.StartsWith("linux-")) { + $env:CGO_ENABLED = "1" + } + & wails build -nopackage -platform ($TargetName.Replace("-", "/")) -o "ScertDesktop" + if ($LASTEXITCODE -ne 0) { + throw "构建 $TargetName 的 Scert 桌面版失败。" + } + $builtExtension = if ($TargetName.StartsWith("windows-")) { ".exe" } else { "" } + $builtDirectory = Join-Path (Join-Path $ProjectDirectory "build") "bin" + $builtPath = Join-Path $builtDirectory ("ScertDesktop" + $builtExtension) + if (-not (Test-Path -LiteralPath $builtPath -PathType Leaf)) { + throw "Wails 构建完成但没有找到桌面产物:$builtPath" + } + Copy-Item -LiteralPath $builtPath -Destination $Destination -Force + } + finally { + Pop-Location + $env:GOOS = $desktopGoOS + $env:GOARCH = $desktopGoArch + $env:CGO_ENABLED = $desktopCGOEnabled + } +} + +function Write-Utf8NoBom { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Content + ) + + [System.IO.File]::WriteAllText( + $Path, + $Content, + [System.Text.UTF8Encoding]::new($false) + ) +} + +function Get-RelativeReleasePath { + param( + [Parameter(Mandatory = $true)] + [string]$BaseDirectory, + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $base = $BaseDirectory.TrimEnd( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar + ) + if (-not $Path.StartsWith($base + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "发布文件不在预期目录内:$Path" + } + return $Path.Substring($base.Length + 1).Replace("\", "/") +} + +function Write-TargetChecksums { + param( + [Parameter(Mandatory = $true)] + [string]$TargetDirectory + ) + + $checksumPath = Join-Path $TargetDirectory "SHA256SUMS" + $lines = [System.Collections.Generic.List[string]]::new() + Get-ChildItem -LiteralPath $TargetDirectory -Recurse -File | + Where-Object { $_.FullName -ne $checksumPath } | + Sort-Object FullName | + ForEach-Object { + $relative = Get-RelativeReleasePath -BaseDirectory $TargetDirectory -Path $_.FullName + $hash = Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 + $lines.Add("$($hash.Hash.ToLowerInvariant()) $relative") + } + Write-Utf8NoBom -Path $checksumPath -Content (($lines -join "`n") + "`n") + return $checksumPath +} + +function Invoke-ReleaseArchive { + param( + [Parameter(Mandatory = $true)] + [string]$Source, + [Parameter(Mandatory = $true)] + [string]$Destination, + [Parameter(Mandatory = $true)] + [ValidateSet("tar.gz", "zip")] + [string]$Format, + [string[]]$Executable = @() + ) + + $arguments = @( + "run", + "./cmd/scert-release", + "--source", $Source, + "--output", $Destination, + "--format", $Format + ) + foreach ($path in $Executable) { + $arguments += @("--executable", $path) + } + + $archiveGoOS = $env:GOOS + $archiveGoArch = $env:GOARCH + $archiveCGOEnabled = $env:CGO_ENABLED + try { + $env:GOOS = "" + $env:GOARCH = "" + $env:CGO_ENABLED = "0" + & go @arguments + if ($LASTEXITCODE -ne 0) { + throw "生成发布归档失败:$Destination" + } + } + finally { + $env:GOOS = $archiveGoOS + $env:GOARCH = $archiveGoArch + $env:CGO_ENABLED = $archiveCGOEnabled + } +} + +$backend = Resolve-SafeDirectory -Path $BackendDirectory -Label "后端目录" +$output = Resolve-SafeDirectory -Path $OutputDirectory -Label "输出目录" +$desktop = Resolve-SafeDirectory -Path $DesktopDirectory -Label "桌面项目目录" +$deploy = Resolve-SafeDirectory -Path $DeployDirectory -Label "部署目录" +$frontend = Resolve-SafeDirectory -Path (Join-Path $scriptDirectory "..\frontend") -Label "前端目录" + +if (-not (Test-Path -LiteralPath (Join-Path $backend "go.mod") -PathType Leaf)) { + throw "后端目录中没有找到 go.mod:$backend" +} +if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "未找到 Go 工具链,请先安装 Go 并加入 PATH。" +} +$npmCommand = if ($env:OS -eq "Windows_NT") { "npm.cmd" } else { "npm" } +if (-not (Get-Command $npmCommand -ErrorAction SilentlyContinue)) { + throw "未找到 npm 工具链,请先安装 Node.js 并加入 PATH。" +} +if (-not (Test-Path -LiteralPath (Join-Path $frontend "package.json") -PathType Leaf)) { + throw "前端目录中没有找到 package.json:$frontend" +} +foreach ($requiredDeploymentFile in @( + "install-linux.sh", + "install-windows.ps1", + "README.md", + "systemd\Scert.service", + "systemd\Scert-admin.service", + "systemd\Scert服务管理说明.md", + "systemd\Scert-admin服务管理说明.md" +)) { + $requiredPath = Join-Path $deploy $requiredDeploymentFile + if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { + throw "部署目录缺少发布文件:$requiredPath" + } +} + +$releaseName = "Scert-$ReleaseDate-$Version" +$releaseDirectory = Join-Path $output $releaseName +if (Test-Path -LiteralPath $releaseDirectory) { + throw "发布目录已存在,拒绝覆盖:$releaseDirectory" +} +New-Item -ItemType Directory -Path $releaseDirectory | Out-Null +$frontendDistribution = Join-Path $releaseDirectory "web-build" + +Write-Host "正在构建 Scert Web 前端……" +Push-Location $frontend +try { + & $npmCommand run build -- --outDir $frontendDistribution --emptyOutDir false + if ($LASTEXITCODE -ne 0) { + throw "构建 Scert Web 前端失败。" + } +} +finally { + Pop-Location +} +if (-not (Test-Path -LiteralPath (Join-Path $frontendDistribution "index.html") -PathType Leaf)) { + throw "前端构建完成但没有找到 index.html:$frontendDistribution" +} + +$oldGoOS = $env:GOOS +$oldGoArch = $env:GOARCH +$oldCGOEnabled = $env:CGO_ENABLED +$manifestArtifacts = [System.Collections.Generic.List[object]]::new() +$hostDesktopTarget = Get-HostDesktopTarget + +Push-Location $backend +try { + foreach ($item in $Target) { + $parts = $item.Split("-", 2) + $goos = $parts[0] + $goarch = $parts[1] + $targetDirectory = Join-Path $releaseDirectory $item + New-Item -ItemType Directory -Path $targetDirectory | Out-Null + + $extension = if ($goos -eq "windows") { ".exe" } else { "" } + $scertPath = Join-Path $targetDirectory ("scert" + $extension) + $adminPath = Join-Path $targetDirectory ("scert-admin" + $extension) + + Write-Host "正在构建 $goos/$goarch 的 Scert……" + Invoke-GoBuild -OperatingSystem $goos -Architecture $goarch -Destination $scertPath -Package "./cmd/scert" + Invoke-GoBuild -OperatingSystem $goos -Architecture $goarch -Destination $adminPath -Package "./cmd/scert-admin" + + $webPath = Join-Path $targetDirectory "web" + New-Item -ItemType Directory -Path $webPath -Force | Out-Null + Copy-Item -Path (Join-Path $frontendDistribution "*") -Destination $webPath -Recurse -Force + + Copy-Item -LiteralPath (Join-Path $deploy "README.md") -Destination (Join-Path $targetDirectory "部署说明.md") + if ($goos -eq "linux") { + Copy-Item -LiteralPath (Join-Path $deploy "install-linux.sh") -Destination (Join-Path $targetDirectory "install-linux.sh") + Copy-Item -LiteralPath (Join-Path $deploy "systemd") -Destination (Join-Path $targetDirectory "systemd") -Recurse + } + else { + Copy-Item -LiteralPath (Join-Path $deploy "install-windows.ps1") -Destination (Join-Path $targetDirectory "install-windows.ps1") + } + + if (-not $SkipDesktop) { + if ($item -eq $hostDesktopTarget) { + $desktopExtension = if ($goos -eq "windows") { ".exe" } else { "" } + $desktopPath = Join-Path $targetDirectory ("ScertDesktop" + $desktopExtension) + Invoke-DesktopBuild -ProjectDirectory $desktop -TargetName $item -Destination $desktopPath + $file = Get-Item -LiteralPath $desktopPath + $hash = Get-FileHash -LiteralPath $desktopPath -Algorithm SHA256 + $manifestArtifacts.Add([ordered]@{ + target = $item + type = "desktop" + file = Get-RelativeReleasePath -BaseDirectory $releaseDirectory -Path $file.FullName + size = $file.Length + sha256 = $hash.Hash.ToLowerInvariant() + }) + } + else { + Write-Warning "当前主机不能直接构建 $item 的 Wails 桌面版;请在对应平台主机上执行同一脚本。" + } + } + + Write-TargetChecksums -TargetDirectory $targetDirectory | Out-Null + foreach ($file in (Get-ChildItem -LiteralPath $targetDirectory -Recurse -File | Sort-Object FullName)) { + $hash = Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 + $manifestArtifacts.Add([ordered]@{ + target = $item + type = "package_file" + file = Get-RelativeReleasePath -BaseDirectory $releaseDirectory -Path $file.FullName + size = $file.Length + sha256 = $hash.Hash.ToLowerInvariant() + }) + } + + if ($goos -eq "linux") { + $archivePath = Join-Path $output "$releaseName-$item.tar.gz" + Invoke-ReleaseArchive -Source $targetDirectory -Destination $archivePath -Format "tar.gz" -Executable @( + "install-linux.sh", "scert", "scert-admin" + ) + } + else { + $archivePath = Join-Path $output "$releaseName-$item.zip" + Invoke-ReleaseArchive -Source $targetDirectory -Destination $archivePath -Format "zip" + } + $archive = Get-Item -LiteralPath $archivePath + $archiveHash = Get-FileHash -LiteralPath $archivePath -Algorithm SHA256 + $manifestArtifacts.Add([ordered]@{ + target = $item + type = "archive" + file = $archive.Name + size = $archive.Length + sha256 = $archiveHash.Hash.ToLowerInvariant() + }) + } +} +finally { + Pop-Location + $env:GOOS = $oldGoOS + $env:GOARCH = $oldGoArch + $env:CGO_ENABLED = $oldCGOEnabled +} + +$manifest = [ordered]@{ + product = "Scert" + release_date = $ReleaseDate + version = $Version + generated_at = [DateTimeOffset]::UtcNow.ToString("o") + artifacts = $manifestArtifacts +} +$manifestPath = Join-Path $releaseDirectory "manifest.json" +Write-Utf8NoBom -Path $manifestPath -Content (($manifest | ConvertTo-Json -Depth 6) + "`n") + +Write-Host "发布构建完成:$releaseDirectory" +Write-Host "校验清单:$manifestPath"