完成初始化证书事务和远程部署互斥并纳入发布构建脚本

This commit is contained in:
xuans 2026-09-15 20:17:34 +08:00
parent 05e05a1a23
commit a4dffb04d4
27 changed files with 1294 additions and 25 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ backend/build/
backend/bin/
frontend/node_modules/
frontend/dist/
frontend/dist-release-*/
*.log
.runtime/
build/*

View File

@ -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}

View File

@ -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 {

View 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
}

View 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-----

View 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-----

View 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-----

View 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-----

View 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` 签发。不能将服务器返回的任意根或中间证书自动提升为信任锚。
升级根证书时须复核官方来源、证书自签名、有效期及回归测试;不得因测试链失败而关闭正式环境信任验证。

View 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)
}
}

View File

@ -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)
}

View File

@ -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)

View 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
}

View 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("失败清理不得删除用户原有凭据")
}
}
}

View File

@ -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 凭据不能为空")

View File

@ -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

View File

@ -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("私钥或私钥备份权限不安全")
}

View File

@ -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, &notCreated) {
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

View File

@ -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) {

View File

@ -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) })
}

View File

@ -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) {

View 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("响应丢失后原始文件内容或权限被破坏")
}
}
})
}
}

View File

@ -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 {

View File

@ -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 通知后必须配置加密凭据引用")
}

View File

@ -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
View 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
View 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"