完成初始化证书事务和远程部署互斥并纳入发布构建脚本
This commit is contained in:
parent
05e05a1a23
commit
a4dffb04d4
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@ backend/build/
|
||||
backend/bin/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/dist-release-*/
|
||||
*.log
|
||||
.runtime/
|
||||
build/*
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 {
|
||||
|
||||
54
backend/internal/certificate/trust.go
Normal file
54
backend/internal/certificate/trust.go
Normal file
@ -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
|
||||
}
|
||||
32
backend/internal/certificate/trust/staging-x1.pem
Normal file
32
backend/internal/certificate/trust/staging-x1.pem
Normal file
@ -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-----
|
||||
15
backend/internal/certificate/trust/staging-x2.pem
Normal file
15
backend/internal/certificate/trust/staging-x2.pem
Normal file
@ -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-----
|
||||
13
backend/internal/certificate/trust/staging-ye.pem
Normal file
13
backend/internal/certificate/trust/staging-ye.pem
Normal file
@ -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-----
|
||||
31
backend/internal/certificate/trust/staging-yr.pem
Normal file
31
backend/internal/certificate/trust/staging-yr.pem
Normal file
@ -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-----
|
||||
21
backend/internal/certificate/trust/来源说明.md
Normal file
21
backend/internal/certificate/trust/来源说明.md
Normal file
@ -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` 签发。不能将服务器返回的任意根或中间证书自动提升为信任锚。
|
||||
|
||||
升级根证书时须复核官方来源、证书自签名、有效期及回归测试;不得因测试链失败而关闭正式环境信任验证。
|
||||
71
backend/internal/certificate/trust_test.go
Normal file
71
backend/internal/certificate/trust_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
118
backend/internal/cli/init_certificate.go
Normal file
118
backend/internal/cli/init_certificate.go
Normal file
@ -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
|
||||
}
|
||||
184
backend/internal/cli/init_certificate_test.go
Normal file
184
backend/internal/cli/init_certificate_test.go
Normal file
@ -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("失败清理不得删除用户原有凭据")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 凭据不能为空")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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("私钥或私钥备份权限不安全")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) })
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
110
backend/internal/deployment/transaction_test.go
Normal file
110
backend/internal/deployment/transaction_test.go
Normal file
@ -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("响应丢失后原始文件内容或权限被破坏")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 通知后必须配置加密凭据引用")
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
47
build/README.md
Normal file
47
build/README.md
Normal file
@ -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 仍需在授权环境复核。
|
||||
389
build/build-release.ps1
Normal file
389
build/build-release.ps1
Normal file
@ -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"
|
||||
Loading…
Reference in New Issue
Block a user