修复权限失效放行并加固证书推送事务和超时回滚
This commit is contained in:
parent
d74f74b631
commit
05e05a1a23
@ -77,3 +77,26 @@ func TestSetupRejectsUnauthorizedDomainBeforeIssuer(t *testing.T) {
|
||||
t.Fatalf("初始化证书申请未拒绝未授权域名:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunMissingAuthorizationCheckerBlocksIssueAndSetup(t *testing.T) {
|
||||
cfg := certificateTestConfig(t.TempDir())
|
||||
cfg.DNS.Provider = "aliyun"
|
||||
item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSingle, nil, false, "staging", "aliyun")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := &recordingProviderFactory{}
|
||||
service := NewService(cfg, &serviceRepository{item: item}, serviceCredentials{}, provider, serviceAccounts{})
|
||||
if _, err := service.Issue(t.Context(), item.ID); err == nil || !strings.Contains(err.Error(), "探测器未初始化") {
|
||||
t.Fatalf("缺失检查器必须阻止签发:%v", err)
|
||||
}
|
||||
if _, err := service.IssueForSetup(t.Context(), SetupCertificateRequest{
|
||||
Domains: []string{"example.com"}, CertificateType: "single", ProviderID: "aliyun",
|
||||
CredentialRef: cfg.DNS.CredentialRef, Environment: "staging",
|
||||
}); err == nil || !strings.Contains(err.Error(), "探测器未初始化") {
|
||||
t.Fatalf("缺失检查器必须阻止初始化证书签发:%v", err)
|
||||
}
|
||||
if provider.providerID != "" {
|
||||
t.Fatal("缺失检查器时不得创建 DNS 挑战 Provider")
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/go-acme/lego/v5/challenge"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
|
||||
@ -270,6 +271,9 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq
|
||||
|
||||
func (s *Service) verifyDNSAuthorization(ctx context.Context, providerID string, domains []string, credentials map[string]string) error {
|
||||
if s == nil || s.authorization == nil {
|
||||
if strings.EqualFold(strings.TrimSpace(providerID), dnsprovider.Aliyun) {
|
||||
return errors.New("阿里云权限探测器未初始化,已阻止证书申请")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, requestedDomain := range domains {
|
||||
|
||||
53
backend/internal/deployment/artifact.go
Normal file
53
backend/internal/deployment/artifact.go
Normal file
@ -0,0 +1,53 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// validateArtifact 是传输前的完整性检查;域名覆盖和环境信任策略由证书服务负责。
|
||||
func validateArtifact(artifact Artifact) error {
|
||||
if len(artifact.Certificate) == 0 || len(artifact.FullChain) == 0 || len(artifact.PrivateKey) == 0 {
|
||||
return errors.New("证书部署文件不完整")
|
||||
}
|
||||
certificate, err := tls.X509KeyPair(artifact.Certificate, artifact.PrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("部署证书与私钥不匹配:%w", err)
|
||||
}
|
||||
chain, err := tls.X509KeyPair(artifact.FullChain, artifact.PrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("部署证书链与私钥不匹配:%w", err)
|
||||
}
|
||||
if !bytes.Equal(certificate.Certificate[0], chain.Certificate[0]) {
|
||||
return errors.New("部署证书与完整证书链的叶子证书不一致")
|
||||
}
|
||||
var previous *x509.Certificate
|
||||
remaining := bytes.TrimSpace(artifact.FullChain)
|
||||
now := time.Now()
|
||||
for len(remaining) > 0 {
|
||||
block, rest := pem.Decode(remaining)
|
||||
if block == nil || block.Type != "CERTIFICATE" || !bytes.HasPrefix(remaining, []byte("-----BEGIN CERTIFICATE-----")) {
|
||||
return errors.New("部署证书链包含无效 PEM 内容")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析部署证书链失败:%w", err)
|
||||
}
|
||||
if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) {
|
||||
return errors.New("部署证书链包含尚未生效或已过期的证书")
|
||||
}
|
||||
if previous != nil {
|
||||
if err := previous.CheckSignatureFrom(cert); err != nil {
|
||||
return fmt.Errorf("部署证书链签名或顺序无效:%w", err)
|
||||
}
|
||||
}
|
||||
previous = cert
|
||||
remaining = bytes.TrimSpace(rest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
backend/internal/deployment/output.go
Normal file
28
backend/internal/deployment/output.go
Normal file
@ -0,0 +1,28 @@
|
||||
package deployment
|
||||
|
||||
import "sync"
|
||||
|
||||
const commandOutputLimit = 64 << 10
|
||||
|
||||
// commandOutput 限制远程标准输出和错误输出占用,防止异常命令耗尽管理端内存。
|
||||
type commandOutput struct {
|
||||
mu sync.Mutex
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (b *commandOutput) Write(data []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
remaining := commandOutputLimit - len(b.data)
|
||||
if remaining > len(data) {
|
||||
remaining = len(data)
|
||||
}
|
||||
b.data = append(b.data, data[:remaining]...)
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (b *commandOutput) Bytes() []byte {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return append([]byte(nil), b.data...)
|
||||
}
|
||||
146
backend/internal/deployment/remote_acceptance_test.go
Normal file
146
backend/internal/deployment/remote_acceptance_test.go
Normal file
@ -0,0 +1,146 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
||||
)
|
||||
|
||||
// TestRemoteDeploymentIsolatedAcceptance 仅在显式配置后运行,不启动业务数据库、DNS 或 ACME。
|
||||
// 每次使用全新隔离目录,保留验收产物;不执行目标机已有服务的校验、重载或重启。
|
||||
func TestRemoteDeploymentIsolatedAcceptance(t *testing.T) {
|
||||
if os.Getenv("SCERT_ACCEPTANCE_CONFIRM") != "isolated-directory" {
|
||||
t.Skip("未启用隔离实机部署验收")
|
||||
}
|
||||
cfg, err := config.Load(os.Getenv("SCERT_ACCEPTANCE_CONFIG"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"master.key", "secrets.json"} {
|
||||
info, err := os.Lstat(filepath.Join(cfg.Paths.Config, name))
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
t.Fatalf("现有凭据文件不可用:%s", name)
|
||||
}
|
||||
}
|
||||
store, err := secretstore.Open(cfg.Paths.Config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(os.Getenv("SCERT_ACCEPTANCE_TARGET"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var server Server
|
||||
if err := json.Unmarshal(data, &server); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := "/etc/ssl/scert-acceptance-" + time.Now().UTC().Format("20060102-150405.000000000")
|
||||
server.CertificatePath = path.Join(root, "cert.pem")
|
||||
server.CertificateChainPath = path.Join(root, "fullchain.pem")
|
||||
server.PrivateKeyPath = path.Join(root, "key.pem")
|
||||
server.ValidationCommand = []string{"/usr/bin/true"}
|
||||
server.ReloadCommand = []string{"/usr/bin/true"}
|
||||
factory := SSHFactory{Secrets: store, Timeout: 10 * time.Second}
|
||||
client, err := factory.Connect(t.Context(), server)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exists, err := client.Exists(t.Context(), root)
|
||||
_ = client.Close()
|
||||
if err != nil || exists {
|
||||
t.Fatalf("隔离目录必须尚不存在:exists=%t err=%v", exists, err)
|
||||
}
|
||||
t.Logf("隔离目标:%s,目录:%s;命令使用 true,不代表真实服务重载验收", server.Address, root)
|
||||
service := NewService(factory)
|
||||
original := testArtifact(t)
|
||||
updated := testArtifact(t)
|
||||
for index, artifact := range []Artifact{original, updated} {
|
||||
report := service.Deploy(t.Context(), []Target{{Server: server}}, artifact)
|
||||
if report.Status != "success" {
|
||||
t.Fatalf("第 %d 次隔离部署失败:%+v", index+1, report)
|
||||
}
|
||||
t.Logf("隔离部署 %d 成功,事务:%s", index+1, report.Results[0].BackupID)
|
||||
}
|
||||
verify := func() {
|
||||
t.Helper()
|
||||
client, err := factory.Connect(t.Context(), server)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
adapter := client.(*sshClientAdapter)
|
||||
for filename, expected := range map[string][]byte{"cert.pem": updated.Certificate, "fullchain.pem": updated.FullChain, "key.pem": updated.PrivateKey} {
|
||||
file, err := adapter.sftp.Open(path.Join(root, filename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual, err := io.ReadAll(file)
|
||||
_ = file.Close()
|
||||
if err != nil || !bytes.Equal(actual, expected) {
|
||||
t.Fatalf("文件内容不一致:%s %v", filename, err)
|
||||
}
|
||||
info, err := adapter.sftp.Stat(path.Join(root, filename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantMode := os.FileMode(0o644)
|
||||
if filename == "key.pem" {
|
||||
wantMode = 0o600
|
||||
}
|
||||
if info.Mode().Perm() != wantMode {
|
||||
t.Fatalf("文件权限错误:%s %o", filename, info.Mode().Perm())
|
||||
}
|
||||
if filename != "key.pem" {
|
||||
t.Logf("内容与权限验证:%s SHA256=%x mode=%o", filename, sha256.Sum256(actual), wantMode)
|
||||
}
|
||||
}
|
||||
files, err := adapter.sftp.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range files {
|
||||
if strings.Contains(file.Name(), ".scert-tmp-") {
|
||||
t.Fatalf("残留临时文件:%s", file.Name())
|
||||
}
|
||||
if strings.HasPrefix(file.Name(), "key.pem") && file.Mode().Perm() != 0o600 {
|
||||
t.Fatal("私钥或私钥备份权限不安全")
|
||||
}
|
||||
}
|
||||
}
|
||||
verify()
|
||||
for _, scenario := range []string{"validation_failure", "reload_failure", "validation_timeout"} {
|
||||
candidate := server
|
||||
ctx := t.Context()
|
||||
cancel := func() {}
|
||||
switch scenario {
|
||||
case "validation_failure":
|
||||
candidate.ValidationCommand = []string{"/usr/bin/false"}
|
||||
case "reload_failure":
|
||||
candidate.ReloadCommand = []string{"/usr/bin/false"}
|
||||
case "validation_timeout":
|
||||
candidate.ValidationCommand = []string{"/usr/bin/sleep", "30"}
|
||||
ctx, cancel = context.WithTimeout(ctx, 4*time.Second)
|
||||
}
|
||||
report := service.Deploy(ctx, []Target{{Server: candidate}}, original)
|
||||
cancel()
|
||||
if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "文件已回滚") || strings.Contains(report.Results[0].Message, "自动回滚失败") {
|
||||
t.Fatalf("故障场景未安全回滚 %s:%+v", scenario, report)
|
||||
}
|
||||
if scenario == "validation_timeout" && !strings.Contains(report.Results[0].Message, "deadline exceeded") {
|
||||
t.Fatalf("必须实际触发超时:%+v", report)
|
||||
}
|
||||
t.Logf("故障场景 %s:%s", scenario, report.Results[0].Message)
|
||||
verify()
|
||||
}
|
||||
}
|
||||
@ -109,6 +109,8 @@ func (s *Service) DeploySaved(ctx context.Context, targets []Target, saved Saved
|
||||
}
|
||||
|
||||
func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifact) Result {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
server = server.Normalize()
|
||||
result := Result{ServerID: server.ID, Server: server.Name, Status: "failed"}
|
||||
if err := server.Validate(); err != nil {
|
||||
@ -120,7 +122,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
result.Message = fmt.Sprintf("连接服务器失败:%v", err)
|
||||
return result
|
||||
}
|
||||
defer client.Close()
|
||||
defer func() { _ = client.Close() }()
|
||||
paths := []remoteFile{
|
||||
{path: RemotePath(server.CertificatePath), data: artifact.Certificate, mode: 0o644},
|
||||
{path: RemotePath(server.CertificateChainPath), data: artifact.FullChain, mode: 0o644},
|
||||
@ -137,6 +139,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
return result
|
||||
}
|
||||
backupID := s.backupID()
|
||||
result.BackupID = backupID
|
||||
type backup struct{ original, saved string }
|
||||
backups := make([]backup, 0, len(paths))
|
||||
temporary := make([]string, 0, len(paths))
|
||||
@ -144,6 +147,14 @@ 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
|
||||
}
|
||||
var rollbackErrors []error
|
||||
for _, path := range temporary {
|
||||
if err := client.Remove(rollbackContext, path); err != nil {
|
||||
@ -157,15 +168,31 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
}
|
||||
for index := len(backups) - 1; index >= 0; index-- {
|
||||
item := backups[index]
|
||||
// 备份请求超时可能丢失响应,重连后以目标文件是否存在核实实际结果。
|
||||
exists, err := client.Exists(rollbackContext, item.saved)
|
||||
if err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("检查备份文件 %s 失败:%w", item.saved, err))
|
||||
continue
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if err := client.Rename(rollbackContext, item.saved, item.original); err != nil {
|
||||
rollbackErrors = append(rollbackErrors, fmt.Errorf("恢复备份文件 %s 失败:%w", item.original, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(rollbackErrors...)
|
||||
}
|
||||
reloadAttempted := false
|
||||
failed := func(message string) Result {
|
||||
if rollbackErr := rollback(); rollbackErr != nil {
|
||||
message += fmt.Sprintf(";自动回滚失败:%v", rollbackErr)
|
||||
} else {
|
||||
message += ";文件已回滚"
|
||||
}
|
||||
if reloadAttempted {
|
||||
// 命令可能已部分执行;文件回滚并不证明运行中的服务已经恢复。
|
||||
message += ";服务运行状态未确认,请检查服务并在确认旧配置后重新加载"
|
||||
}
|
||||
result.Message = message
|
||||
return result
|
||||
@ -174,27 +201,31 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
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 {
|
||||
return failed(fmt.Sprintf("上传远程证书失败:%v", err))
|
||||
}
|
||||
}
|
||||
// 所有文件上传完成后才触碰当前版本,避免上传失败造成不必要的证书替换。
|
||||
for _, file := range paths {
|
||||
exists, err := client.Exists(ctx, file.path)
|
||||
if err != nil {
|
||||
return failed(fmt.Sprintf("检查远程文件失败:%v", err))
|
||||
}
|
||||
if exists {
|
||||
backupPath := file.path + ".scert-backup-" + backupID
|
||||
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))
|
||||
}
|
||||
backups = append(backups, backup{original: file.path, saved: backupPath})
|
||||
}
|
||||
temporaryPath := file.path + ".scert-tmp-" + backupID
|
||||
if err := client.Upload(ctx, temporaryPath, file.data, file.mode); err != nil {
|
||||
return failed(fmt.Sprintf("上传远程证书失败:%v", err))
|
||||
}
|
||||
temporary = append(temporary, temporaryPath)
|
||||
installed = append(installed, file.path)
|
||||
if err := client.Rename(ctx, temporaryPath, file.path); err != nil {
|
||||
return failed(fmt.Sprintf("替换远程证书失败:%v", err))
|
||||
}
|
||||
temporary = temporary[:len(temporary)-1]
|
||||
installed = append(installed, file.path)
|
||||
}
|
||||
validationResult, err := client.Run(ctx, server.ValidationCommand)
|
||||
if err != nil || validationResult.ExitCode != 0 {
|
||||
@ -203,6 +234,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
}
|
||||
return failed(fmt.Sprintf("配置校验失败:%v", err))
|
||||
}
|
||||
reloadAttempted = true
|
||||
commandResult, err := client.Run(ctx, server.ReloadCommand)
|
||||
if err != nil || commandResult.ExitCode != 0 {
|
||||
if err == nil {
|
||||
@ -237,13 +269,6 @@ func validateDeploymentPaths(files []remoteFile) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateArtifact(artifact Artifact) error {
|
||||
if len(artifact.Certificate) == 0 || len(artifact.FullChain) == 0 || len(artifact.PrivateKey) == 0 {
|
||||
return errors.New("证书部署文件不完整")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) backupID() string {
|
||||
var random [6]byte
|
||||
_, _ = rand.Read(random[:])
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@ -38,6 +45,7 @@ func (c *memoryClient) Exists(_ context.Context, path string) (bool, error) {
|
||||
|
||||
func (c *memoryClient) Upload(_ context.Context, path string, data []byte, mode uint32) error {
|
||||
if c.uploadError != "" && strings.Contains(path, c.uploadError) {
|
||||
c.files[path] = memoryFile{data: append([]byte(nil), data[:len(data)/2]...), mode: mode}
|
||||
return errors.New("模拟上传失败")
|
||||
}
|
||||
c.files[path] = memoryFile{data: append([]byte(nil), data...), mode: mode}
|
||||
@ -105,11 +113,12 @@ func TestDeployReplacesFilesAndKeepsBackup(t *testing.T) {
|
||||
client.files[server.PrivateKeyPath] = memoryFile{data: []byte("old-key"), mode: 0o600}
|
||||
service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}})
|
||||
service.now = func() time.Time { return time.Date(2026, 8, 27, 1, 2, 3, 4, time.UTC) }
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact())
|
||||
artifact := testArtifact(t)
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, artifact)
|
||||
if report.Status != "success" || report.Success != 1 {
|
||||
t.Fatalf("部署结果错误:%+v", report)
|
||||
}
|
||||
if string(client.files[server.CertificatePath].data) != "new-cert" || client.files[server.PrivateKeyPath].mode != 0o600 {
|
||||
if !bytes.Equal(client.files[server.CertificatePath].data, artifact.Certificate) || client.files[server.PrivateKeyPath].mode != 0o600 {
|
||||
t.Fatalf("远程证书内容或权限错误:%+v", client.files)
|
||||
}
|
||||
if len(client.commands) != 2 || len(client.commands[0]) != 2 || client.commands[0][1] != "-t" || len(client.commands[1]) != 3 || client.commands[1][0] != "nginx" {
|
||||
@ -132,7 +141,7 @@ func TestDeployRollsBackWhenReloadFails(t *testing.T) {
|
||||
server := testServer("server-1")
|
||||
client.files[server.CertificatePath] = memoryFile{data: []byte("old-cert"), mode: 0o644}
|
||||
service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}})
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact())
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t))
|
||||
if report.Status != "failed" || report.Failed != 1 {
|
||||
t.Fatalf("reload 失败结果错误:%+v", report)
|
||||
}
|
||||
@ -157,7 +166,7 @@ func TestDeployRollsBackWhenValidationFailsWithoutReload(t *testing.T) {
|
||||
client.files[server.CertificateChainPath] = memoryFile{data: []byte("old-chain"), mode: 0o644}
|
||||
client.files[server.PrivateKeyPath] = memoryFile{data: []byte("old-key"), mode: 0o600}
|
||||
service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}})
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact())
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t))
|
||||
if report.Status != "failed" || report.Failed != 1 || !strings.Contains(report.Results[0].Message, "配置校验失败") {
|
||||
t.Fatalf("配置校验失败结果错误:%+v", report)
|
||||
}
|
||||
@ -181,7 +190,7 @@ func TestDeployReportsRollbackFailure(t *testing.T) {
|
||||
server := testServer("server-1")
|
||||
client.files[server.CertificatePath] = memoryFile{data: []byte("old-cert"), mode: 0o644}
|
||||
service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}})
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact())
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t))
|
||||
if report.Status != "failed" || report.Failed != 1 {
|
||||
t.Fatalf("回滚失败结果错误:%+v", report)
|
||||
}
|
||||
@ -197,19 +206,41 @@ func TestDeployReportsPartialSuccess(t *testing.T) {
|
||||
firstServer := testServer("first")
|
||||
secondServer := testServer("second")
|
||||
service := NewService(memoryFactory{clients: map[string]*memoryClient{"first": first, "second": second}})
|
||||
report := service.Deploy(context.Background(), []Target{{Server: firstServer}, {Server: secondServer}}, testArtifact())
|
||||
report := service.Deploy(context.Background(), []Target{{Server: firstServer}, {Server: secondServer}}, testArtifact(t))
|
||||
if report.Status != "partial" || report.Success != 1 || report.Failed != 1 {
|
||||
t.Fatalf("部分成功汇总错误:%+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCleansPartialPrivateKeyUploadWithoutReplacingCurrentFiles(t *testing.T) {
|
||||
client := newMemoryClient()
|
||||
client.uploadError = "key.pem"
|
||||
server := testServer("partial-key")
|
||||
for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} {
|
||||
client.files[path] = memoryFile{data: []byte("original"), mode: 0o600}
|
||||
}
|
||||
service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}})
|
||||
report := service.Deploy(t.Context(), []Target{{Server: server}}, testArtifact(t))
|
||||
if report.Status != "failed" || report.Results[0].BackupID == "" {
|
||||
t.Fatalf("失败报告必须包含事务标识:%+v", report)
|
||||
}
|
||||
if len(client.files) != 3 || len(client.commands) != 0 {
|
||||
t.Fatalf("部分上传失败不得残留临时文件或执行命令:files=%v commands=%v", client.files, client.commands)
|
||||
}
|
||||
for path, file := range client.files {
|
||||
if string(file.data) != "original" || file.mode != 0o600 {
|
||||
t.Fatalf("上传阶段不得替换原文件:%s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployRejectsDuplicateCertificatePaths(t *testing.T) {
|
||||
client := newMemoryClient()
|
||||
server := testServer("server-1")
|
||||
server.PrivateKeyPath = server.CertificatePath
|
||||
service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}})
|
||||
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact())
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t))
|
||||
if report.Status != "failed" || report.Failed != 1 {
|
||||
t.Fatalf("重复远程路径应直接失败:%+v", report)
|
||||
}
|
||||
@ -227,7 +258,7 @@ func TestDeployRejectsRelativeCertificatePaths(t *testing.T) {
|
||||
server.CertificatePath = "cert.pem"
|
||||
service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}})
|
||||
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact())
|
||||
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t))
|
||||
if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "必须是绝对路径") {
|
||||
t.Fatalf("相对远程路径应被拒绝:%+v", report)
|
||||
}
|
||||
@ -242,6 +273,48 @@ func testServer(id string) Server {
|
||||
}
|
||||
}
|
||||
|
||||
func testArtifact() Artifact {
|
||||
return Artifact{Certificate: []byte("new-cert"), FullChain: []byte("new-chain"), PrivateKey: []byte("new-key")}
|
||||
func testArtifact(t *testing.T) Artifact {
|
||||
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{"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)
|
||||
}
|
||||
privateKey, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
return Artifact{Certificate: cert, FullChain: append([]byte(nil), cert...), PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKey})}
|
||||
}
|
||||
|
||||
func TestDeployRejectsCorruptArtifactsBeforeConnecting(t *testing.T) {
|
||||
for _, scenario := range []string{"invalid_pem", "wrong_key", "wrong_leaf", "garbage_chain"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
artifact := testArtifact(t)
|
||||
switch scenario {
|
||||
case "invalid_pem":
|
||||
artifact.Certificate = []byte("invalid")
|
||||
case "wrong_key":
|
||||
artifact.PrivateKey = testArtifact(t).PrivateKey
|
||||
case "wrong_leaf":
|
||||
artifact.FullChain = testArtifact(t).FullChain
|
||||
case "garbage_chain":
|
||||
artifact.FullChain = append(artifact.FullChain, []byte("invalid")...)
|
||||
}
|
||||
service := NewService(memoryFactory{})
|
||||
report := service.Deploy(t.Context(), []Target{{Server: testServer("invalid")}}, artifact)
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("无效产物未被拒绝:%+v", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
@ -64,12 +65,16 @@ func (f SSHFactory) Connect(ctx context.Context, server Server) (Client, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sftpClient, err := sftp.NewClient(sshClient)
|
||||
adapter := &sshClientAdapter{ssh: sshClient}
|
||||
sftpClient, err := sshOperation(timeoutContext, adapter, func() (*sftp.Client, error) {
|
||||
return sftp.NewClient(sshClient)
|
||||
})
|
||||
if err != nil {
|
||||
_ = sshClient.Close()
|
||||
return nil, fmt.Errorf("创建 SFTP 客户端失败:%w", err)
|
||||
}
|
||||
return &sshClientAdapter{ssh: sshClient, sftp: sftpClient}, nil
|
||||
adapter.sftp = sftpClient
|
||||
return adapter, nil
|
||||
}
|
||||
|
||||
func (f SSHFactory) secureHostKeyAlgorithms() ([]string, error) {
|
||||
@ -138,7 +143,13 @@ func connectSSHClient(
|
||||
return nil
|
||||
},
|
||||
}
|
||||
stopCancel := context.AfterFunc(ctx, func() { _ = connection.Close() })
|
||||
clientConn, channels, requests, handshakeErr := ssh.NewClientConn(connection, address, config)
|
||||
stopCancel()
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, fmt.Errorf("SSH 握手已取消:%w", err)
|
||||
}
|
||||
if handshakeErr == nil {
|
||||
if err := connection.SetDeadline(time.Time{}); err != nil {
|
||||
_ = clientConn.Close()
|
||||
@ -174,64 +185,96 @@ func connectSSHClient(
|
||||
}
|
||||
|
||||
type sshClientAdapter struct {
|
||||
ssh *ssh.Client
|
||||
sftp *sftp.Client
|
||||
ssh *ssh.Client
|
||||
sftp *sftp.Client
|
||||
disconnected atomic.Bool
|
||||
}
|
||||
|
||||
// sshOperation 取消时关闭传输并等待操作退出,避免回滚与仍在写入的操作并行。
|
||||
// 已断开的连接由部署事务使用独立回滚上下文重新建立,仍严格校验主机指纹。
|
||||
func sshOperation[T any](ctx context.Context, c *sshClientAdapter, operation func() (T, error)) (T, error) {
|
||||
var zero T
|
||||
if err := ctx.Err(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
type outcome struct {
|
||||
value T
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
value, err := operation()
|
||||
done <- outcome{value, err}
|
||||
}()
|
||||
select {
|
||||
case result := <-done:
|
||||
if err := ctx.Err(); err != nil {
|
||||
c.disconnected.Store(true)
|
||||
_ = c.ssh.Close()
|
||||
return zero, err
|
||||
}
|
||||
return result.value, result.err
|
||||
case <-ctx.Done():
|
||||
c.disconnected.Store(true)
|
||||
_ = c.ssh.Close()
|
||||
<-done
|
||||
return zero, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *sshClientAdapter) Disconnected() bool { return c.disconnected.Load() }
|
||||
|
||||
func (c *sshClientAdapter) fileOperation(ctx context.Context, operation func() error) error {
|
||||
_, err := sshOperation(ctx, c, func() (struct{}, error) { return struct{}{}, operation() })
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sshClientAdapter) MkdirAll(ctx context.Context, path string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.sftp.MkdirAll(path)
|
||||
return c.fileOperation(ctx, func() error { return c.sftp.MkdirAll(path) })
|
||||
}
|
||||
|
||||
func (c *sshClientAdapter) Exists(ctx context.Context, path string) (bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err := c.sftp.Stat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
return sshOperation(ctx, c, func() (bool, error) {
|
||||
_, err := c.sftp.Stat(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
})
|
||||
}
|
||||
|
||||
func (c *sshClientAdapter) Upload(ctx context.Context, path string, data []byte, mode uint32) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := c.sftp.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
closeFile := func() error { return file.Close() }
|
||||
if _, err := file.Write(data); err != nil {
|
||||
_ = closeFile()
|
||||
return err
|
||||
}
|
||||
if err := file.Chmod(os.FileMode(mode)); err != nil {
|
||||
_ = closeFile()
|
||||
return err
|
||||
}
|
||||
return closeFile()
|
||||
return 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
|
||||
}
|
||||
// 先收紧空文件权限,再写入任何证书或私钥内容。
|
||||
if err := file.Chmod(os.FileMode(mode)); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := file.Write(data); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func (c *sshClientAdapter) Rename(ctx context.Context, source, target string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.sftp.Rename(source, target)
|
||||
return c.fileOperation(ctx, func() error { return c.sftp.Rename(source, target) })
|
||||
}
|
||||
|
||||
func (c *sshClientAdapter) Remove(ctx context.Context, path string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return c.fileOperation(ctx, func() error {
|
||||
err := c.sftp.Remove(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
err := c.sftp.Remove(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (c *sshClientAdapter) Run(ctx context.Context, command []string) (CommandResult, error) {
|
||||
@ -241,12 +284,20 @@ func (c *sshClientAdapter) Run(ctx context.Context, command []string) (CommandRe
|
||||
if err := validateCommand(command); err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
return sshOperation(ctx, c, func() (CommandResult, error) { return c.runCommand(command) })
|
||||
}
|
||||
|
||||
func (c *sshClientAdapter) runCommand(command []string) (CommandResult, error) {
|
||||
session, err := c.ssh.NewSession()
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
defer session.Close()
|
||||
output, err := session.CombinedOutput(joinShellArgs(command))
|
||||
outputBuffer := &commandOutput{}
|
||||
session.Stdout = outputBuffer
|
||||
session.Stderr = outputBuffer
|
||||
err = session.Run(joinShellArgs(command))
|
||||
output := outputBuffer.Bytes()
|
||||
if err != nil {
|
||||
var exitError *ssh.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
@ -254,21 +305,18 @@ func (c *sshClientAdapter) Run(ctx context.Context, command []string) (CommandRe
|
||||
}
|
||||
return CommandResult{Output: output, ExitCode: -1}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return CommandResult{Output: output, ExitCode: -1}, err
|
||||
}
|
||||
return CommandResult{Output: output}, nil
|
||||
}
|
||||
|
||||
func (c *sshClientAdapter) Close() error {
|
||||
var first error
|
||||
if c.sftp != nil {
|
||||
if err := c.sftp.Close(); err != nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
// 先关闭传输,避免无响应的 SFTP 服务阻塞关闭过程。
|
||||
if c.ssh != nil {
|
||||
if err := c.ssh.Close(); err != nil && first == nil {
|
||||
c.disconnected.Store(true)
|
||||
first = c.ssh.Close()
|
||||
}
|
||||
if c.sftp != nil {
|
||||
if err := c.sftp.Close(); err != nil && first == nil {
|
||||
first = err
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,17 +182,27 @@ func TestSSHFactoryFiltersUnsupportedAndDuplicateHostKeyAlgorithms(t *testing.T)
|
||||
}
|
||||
|
||||
type testSSHServer struct {
|
||||
listener net.Listener
|
||||
accepted atomic.Int32
|
||||
host string
|
||||
port int
|
||||
config *ssh.ServerConfig
|
||||
listener net.Listener
|
||||
accepted atomic.Int32
|
||||
host string
|
||||
port int
|
||||
config *ssh.ServerConfig
|
||||
channelHandler func(ssh.Channel, <-chan *ssh.Request)
|
||||
}
|
||||
|
||||
func startTestSSHServer(
|
||||
t *testing.T,
|
||||
authorize func(ssh.PublicKey) error,
|
||||
hostSigners ...ssh.Signer,
|
||||
) *testSSHServer {
|
||||
return startTestSSHServerWithHandler(t, authorize, serveTestSFTP, hostSigners...)
|
||||
}
|
||||
|
||||
func startTestSSHServerWithHandler(
|
||||
t *testing.T,
|
||||
authorize func(ssh.PublicKey) error,
|
||||
handler func(ssh.Channel, <-chan *ssh.Request),
|
||||
hostSigners ...ssh.Signer,
|
||||
) *testSSHServer {
|
||||
t.Helper()
|
||||
config := &ssh.ServerConfig{
|
||||
@ -218,10 +228,11 @@ func startTestSSHServer(
|
||||
t.Fatalf("解析测试 SSH 端口失败:%v", err)
|
||||
}
|
||||
server := &testSSHServer{
|
||||
listener: listener,
|
||||
host: host,
|
||||
port: port,
|
||||
config: config,
|
||||
listener: listener,
|
||||
host: host,
|
||||
port: port,
|
||||
config: config,
|
||||
channelHandler: handler,
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
listener.Close()
|
||||
@ -257,7 +268,7 @@ func (s *testSSHServer) serveConnection(connection net.Conn) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go serveTestSFTP(channel, requests)
|
||||
go s.channelHandler(channel, requests)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
255
backend/internal/deployment/ssh_safety_test.go
Normal file
255
backend/internal/deployment/ssh_safety_test.go
Normal file
@ -0,0 +1,255 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type observedSFTP struct {
|
||||
handlers sftp.Handlers
|
||||
mode atomic.Uint32
|
||||
writes atomic.Int32
|
||||
unsafeWrite atomic.Bool
|
||||
exclusive atomic.Bool
|
||||
denyMode bool
|
||||
}
|
||||
|
||||
func (h *observedSFTP) Filewrite(request *sftp.Request) (io.WriterAt, error) {
|
||||
h.exclusive.Store(request.Pflags().Excl && !request.Pflags().Trunc)
|
||||
writer, err := h.handlers.FilePut.Filewrite(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &observedWriter{WriterAt: writer, owner: h}, nil
|
||||
}
|
||||
|
||||
func (h *observedSFTP) Filecmd(request *sftp.Request) error {
|
||||
if request.Method == "Setstat" {
|
||||
if h.denyMode {
|
||||
return errors.New("测试拒绝权限设置")
|
||||
}
|
||||
h.mode.Store(request.Attributes().Mode & 0o777)
|
||||
}
|
||||
return h.handlers.FileCmd.Filecmd(request)
|
||||
}
|
||||
|
||||
type observedWriter struct {
|
||||
io.WriterAt
|
||||
owner *observedSFTP
|
||||
}
|
||||
|
||||
func (w *observedWriter) WriteAt(data []byte, offset int64) (int, error) {
|
||||
w.owner.writes.Add(1)
|
||||
if w.owner.mode.Load() != 0o600 {
|
||||
w.owner.unsafeWrite.Store(true)
|
||||
}
|
||||
return w.WriterAt.WriteAt(data, offset)
|
||||
}
|
||||
|
||||
func (w *observedWriter) Close() error {
|
||||
if closer, ok := w.WriterAt.(io.Closer); ok {
|
||||
return closer.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safetyFactory(t *testing.T, handler func(ssh.Channel, <-chan *ssh.Request)) (SSHFactory, Server) {
|
||||
t.Helper()
|
||||
_, privateKey := newTestSSHSigner(t)
|
||||
hostSigner, _ := newTestSSHSigner(t)
|
||||
server := startTestSSHServerWithHandler(t, func(ssh.PublicKey) error { return nil }, handler, hostSigner)
|
||||
model := testSSHServerModel(server, "secret://ssh/safety", ssh.FingerprintSHA256(hostSigner.PublicKey()))
|
||||
return SSHFactory{
|
||||
Secrets: staticSecretReader{model.PrivateKeyRef: {"private_key": privateKey}},
|
||||
Timeout: time.Second, HostKeyAlgorithms: []string{ssh.KeyAlgoED25519},
|
||||
}, model
|
||||
}
|
||||
|
||||
func requestSFTPHandler(handlers sftp.Handlers, stallVersion, stallCommand bool) func(ssh.Channel, <-chan *ssh.Request) {
|
||||
return func(channel ssh.Channel, requests <-chan *ssh.Request) {
|
||||
defer channel.Close()
|
||||
for request := range requests {
|
||||
switch request.Type {
|
||||
case "subsystem":
|
||||
_ = request.Reply(true, nil)
|
||||
if stallVersion {
|
||||
_, _ = io.Copy(io.Discard, channel)
|
||||
return
|
||||
}
|
||||
server := sftp.NewRequestServer(channel, handlers)
|
||||
_ = server.Serve()
|
||||
_ = server.Close()
|
||||
return
|
||||
case "exec":
|
||||
_ = request.Reply(true, nil)
|
||||
if stallCommand {
|
||||
for request := range requests {
|
||||
_ = request.Reply(false, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
_, _ = channel.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{0}))
|
||||
return
|
||||
default:
|
||||
_ = request.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHUploadRestrictsPermissionsBeforeWritingAndRejectsExistingFile(t *testing.T) {
|
||||
for _, denyMode := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "success", true: "chmod_denied"}[denyMode], func(t *testing.T) {
|
||||
observer := &observedSFTP{handlers: sftp.InMemHandler(), denyMode: denyMode}
|
||||
handlers := observer.handlers
|
||||
handlers.FilePut = observer
|
||||
handlers.FileCmd = observer
|
||||
factory, server := safetyFactory(t, requestSFTPHandler(handlers, false, false))
|
||||
client, err := factory.Connect(t.Context(), server)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
err = client.Upload(t.Context(), "/key.tmp", []byte("private-test-key"), 0o600)
|
||||
if denyMode {
|
||||
if err == nil || observer.writes.Load() != 0 {
|
||||
t.Fatal("权限设置失败后不得写入私钥")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if observer.unsafeWrite.Load() || observer.writes.Load() == 0 || !observer.exclusive.Load() {
|
||||
t.Fatal("上传必须独占创建并在权限收紧后才写入")
|
||||
}
|
||||
if err := client.Upload(t.Context(), "/key.tmp", []byte("overwrite"), 0o600); err == nil {
|
||||
t.Fatal("不得覆盖已有上传目标")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHFactoryCancelsUnresponsiveSFTPInitialization(t *testing.T) {
|
||||
factory, server := safetyFactory(t, requestSFTPHandler(sftp.InMemHandler(), true, false))
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if _, err := factory.Connect(ctx, server); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("SFTP 握手无响应必须超时:%v", err)
|
||||
}
|
||||
if time.Since(start) > 2*time.Second {
|
||||
t.Fatal("SFTP 握手取消不及时")
|
||||
}
|
||||
}
|
||||
|
||||
type stalledLister struct{ release <-chan struct{} }
|
||||
|
||||
func (l stalledLister) Filelist(*sftp.Request) (sftp.ListerAt, error) {
|
||||
<-l.release
|
||||
return nil, errors.New("测试结束")
|
||||
}
|
||||
|
||||
func TestSSHOperationsCancelUnresponsiveServer(t *testing.T) {
|
||||
for _, operation := range []string{"stat", "command"} {
|
||||
t.Run(operation, func(t *testing.T) {
|
||||
handlers := sftp.InMemHandler()
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
if operation == "stat" {
|
||||
handlers.FileList = stalledLister{release: release}
|
||||
}
|
||||
factory, server := safetyFactory(t, requestSFTPHandler(handlers, false, true))
|
||||
client, err := factory.Connect(t.Context(), server)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if operation == "stat" {
|
||||
_, err = client.Exists(ctx, "/test")
|
||||
} else {
|
||||
_, err = client.Run(ctx, []string{"sleep", "60"})
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("无响应操作未及时取消:%v", err)
|
||||
}
|
||||
if !client.(*sshClientAdapter).Disconnected() {
|
||||
t.Fatal("取消后必须标记传输已断开以触发重连回滚")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHDeploymentReconnectsAndRollsBackAfterCommandTimeout(t *testing.T) {
|
||||
handlers := sftp.InMemHandler()
|
||||
factory, server := safetyFactory(t, requestSFTPHandler(handlers, false, true))
|
||||
client, err := factory.Connect(t.Context(), server)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.MkdirAll(t.Context(), "/tmp"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} {
|
||||
if err := client.Upload(t.Context(), path, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = client.Close()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 400*time.Millisecond)
|
||||
defer cancel()
|
||||
report := NewService(factory).Deploy(ctx, []Target{{Server: server}}, testArtifact(t))
|
||||
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
t.Fatal("测试必须实际触发命令超时,而不是直接执行失败")
|
||||
}
|
||||
if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "文件已回滚") || strings.Contains(report.Results[0].Message, "自动回滚失败") {
|
||||
t.Fatalf("超时后应重连完成文件回滚:%+v", report)
|
||||
}
|
||||
client, err = factory.Connect(t.Context(), server)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
adapter := client.(*sshClientAdapter)
|
||||
for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} {
|
||||
file, err := adapter.sftp.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(file)
|
||||
_ = file.Close()
|
||||
if err != nil || !bytes.Equal(data, []byte("original")) {
|
||||
t.Fatalf("原文件未恢复:%s %v", path, err)
|
||||
}
|
||||
}
|
||||
files, err := adapter.sftp.ReadDir("/tmp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) != 3 {
|
||||
t.Fatalf("回滚后残留事务文件:%v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandOutputIsBounded(t *testing.T) {
|
||||
buffer := &commandOutput{}
|
||||
data := bytes.Repeat([]byte("x"), commandOutputLimit*3)
|
||||
if written, err := buffer.Write(data); written != len(data) || err != nil {
|
||||
t.Fatal("截断不应阻塞远程输出")
|
||||
}
|
||||
if len(buffer.Bytes()) != commandOutputLimit {
|
||||
t.Fatal("远程输出未限制内存占用")
|
||||
}
|
||||
}
|
||||
@ -3,13 +3,11 @@ package dnsprovider
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
@ -19,10 +17,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
aliyunDefaultRegion = "cn-hangzhou"
|
||||
aliyunAuthorizationTTL = 600
|
||||
aliyunAuthorizationTimeout = 30 * time.Second
|
||||
aliyunAuthorizationCacheTTL = 10 * time.Minute
|
||||
aliyunDefaultRegion = "cn-hangzhou"
|
||||
aliyunAuthorizationTTL = 600
|
||||
aliyunAuthorizationTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// AuthorizationDomain 描述一个阿里云 DNS 根域名的授权探测结果。
|
||||
@ -48,14 +45,12 @@ type AuthorizationReport struct {
|
||||
|
||||
// AliyunAuthorizationChecker 负责发现并证明 RAM 账号对阿里云 DNS 根域名的实际写权限。
|
||||
type AliyunAuthorizationChecker struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
cache map[string]time.Time
|
||||
verifyDomainFunc func(context.Context, string, map[string]string) error
|
||||
}
|
||||
|
||||
func NewAliyunAuthorizationChecker() *AliyunAuthorizationChecker {
|
||||
return &AliyunAuthorizationChecker{now: time.Now, cache: make(map[string]time.Time)}
|
||||
return &AliyunAuthorizationChecker{now: time.Now}
|
||||
}
|
||||
|
||||
func (c *AliyunAuthorizationChecker) Supports(providerID string) bool {
|
||||
@ -158,21 +153,26 @@ func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain
|
||||
return report, fmt.Errorf("阿里云 RAM 账号无法写入域名 %s:%w(建议:%s)", requestedDomain, lastErr, report.Diagnosis)
|
||||
}
|
||||
|
||||
// VerifyDomain 实现证书服务使用的最小授权检查接口。
|
||||
// VerifyDomain 每次重新证明写入和清理能力,不使用跨请求缓存掩盖撤权。
|
||||
func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error {
|
||||
if !c.Supports(providerID) {
|
||||
return nil
|
||||
}
|
||||
if c == nil {
|
||||
return errors.New("阿里云权限探测器未初始化")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
requestedDomain = normalizeAuthorizationDomain(requestedDomain)
|
||||
if c.cached(requestedDomain, credentials) {
|
||||
return nil
|
||||
if !domain.IsValidName(requestedDomain) {
|
||||
return fmt.Errorf("待验证域名格式无效:%s", requestedDomain)
|
||||
}
|
||||
if _, err := ValidateCredentials(Aliyun, credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.verifyDomainFunc != nil {
|
||||
if err := c.verifyDomainFunc(ctx, requestedDomain, credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
c.remember(requestedDomain, credentials)
|
||||
return nil
|
||||
return c.verifyDomainFunc(ctx, requestedDomain, credentials)
|
||||
}
|
||||
report, err := c.Verify(ctx, requestedDomain, credentials)
|
||||
if err != nil {
|
||||
@ -181,23 +181,11 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI
|
||||
if !report.Verified {
|
||||
return errors.New("阿里云 DNS 域名授权未通过")
|
||||
}
|
||||
c.remember(requestedDomain, credentials)
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateDomain 在实际 ACME 写入失败后清除短时授权缓存,确保权限收缩能够被重新探测。
|
||||
// InvalidateDomain 保留旧调用方接口;授权检查已不再缓存,无需额外失效操作。
|
||||
func (c *AliyunAuthorizationChecker) InvalidateDomain(providerID, requestedDomain string, credentials map[string]string) {
|
||||
if c == nil || !c.Supports(providerID) {
|
||||
return
|
||||
}
|
||||
key := authorizationCacheKey(providerID, requestedDomain, credentials)
|
||||
c.mu.Lock()
|
||||
if c.cache == nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(c.cache, key)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *AliyunAuthorizationChecker) currentTime() time.Time {
|
||||
@ -321,49 +309,6 @@ func authorizationZoneCandidates(requested string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *AliyunAuthorizationChecker) cached(requestedDomain string, credentials map[string]string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
now := c.currentTime().UTC()
|
||||
key := authorizationCacheKey(Aliyun, requestedDomain, credentials)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
expiresAt, ok := c.cache[key]
|
||||
if !ok || !expiresAt.After(now) {
|
||||
delete(c.cache, key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *AliyunAuthorizationChecker) remember(requestedDomain string, credentials map[string]string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
key := authorizationCacheKey(Aliyun, requestedDomain, credentials)
|
||||
c.mu.Lock()
|
||||
if c.cache == nil {
|
||||
c.cache = make(map[string]time.Time)
|
||||
}
|
||||
c.cache[key] = c.currentTime().UTC().Add(aliyunAuthorizationCacheTTL)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func authorizationCacheKey(providerID, requestedDomain string, credentials map[string]string) string {
|
||||
cleaned, _ := ValidateCredentials(providerID, credentials)
|
||||
keys := make([]string, 0, len(cleaned))
|
||||
for key := range cleaned {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
hash := sha256.New()
|
||||
for _, key := range keys {
|
||||
fmt.Fprintf(hash, "%d:%s=%d:%s\n", len(key), key, len(cleaned[key]), cleaned[key])
|
||||
}
|
||||
return fmt.Sprintf("%s|%s|%x", strings.ToLower(strings.TrimSpace(providerID)), normalizeAuthorizationDomain(requestedDomain), hash.Sum(nil))
|
||||
}
|
||||
|
||||
func markAuthorizationFailure(report *AuthorizationReport, domainName string, err error) {
|
||||
for index := range report.Domains {
|
||||
if report.Domains[index].Domain == domainName {
|
||||
|
||||
@ -20,13 +20,17 @@ func TestAuthorizationZoneCandidatesPreferSpecificZones(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunAuthorizationCheckerCachesSuccessfulAuthorization(t *testing.T) {
|
||||
func TestAliyunAuthorizationCheckerRechecksEveryRequestAndDetectsRevocation(t *testing.T) {
|
||||
now := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
revoked := false
|
||||
checker := NewAliyunAuthorizationChecker()
|
||||
checker.now = func() time.Time { return now }
|
||||
checker.verifyDomainFunc = func(context.Context, string, map[string]string) error {
|
||||
calls++
|
||||
if revoked {
|
||||
return errors.New("Forbidden.RAM")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
credentials := map[string]string{
|
||||
@ -40,16 +44,16 @@ func TestAliyunAuthorizationCheckerCachesSuccessfulAuthorization(t *testing.T) {
|
||||
if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err != nil {
|
||||
t.Fatalf("缓存授权检查失败:%v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("短时授权缓存未生效:calls=%d", calls)
|
||||
if calls != 2 {
|
||||
t.Fatalf("每次操作必须重新验证权限:calls=%d", calls)
|
||||
}
|
||||
|
||||
now = now.Add(aliyunAuthorizationCacheTTL + time.Second)
|
||||
if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err != nil {
|
||||
t.Fatalf("缓存过期后的授权检查失败:%v", err)
|
||||
revoked = true
|
||||
if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err == nil {
|
||||
t.Fatal("撤权后不得复用之前的成功结果")
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("授权缓存过期后未重新探测:calls=%d", calls)
|
||||
if calls != 3 {
|
||||
t.Fatalf("撤权后未重新探测:calls=%d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -159,8 +159,8 @@ func (s *Server) checkAliyunDNSDomain(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainName string) error {
|
||||
if s == nil || s.aliyunChecker == nil {
|
||||
return nil
|
||||
if s == nil {
|
||||
return errors.New("域名授权检查服务未初始化")
|
||||
}
|
||||
s.configMu.RLock()
|
||||
setupReady := s.config.SetupReady
|
||||
@ -168,13 +168,22 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
|
||||
credentialRef := strings.TrimSpace(s.config.DNS.CredentialRef)
|
||||
s.configMu.RUnlock()
|
||||
|
||||
if !setupReady || provider != dnsprovider.Aliyun || credentialRef == "" {
|
||||
if provider != dnsprovider.Aliyun {
|
||||
return nil
|
||||
}
|
||||
if !setupReady {
|
||||
return errors.New("系统初始化尚未完成,不能验证域名授权")
|
||||
}
|
||||
if s.aliyunChecker == nil {
|
||||
return errors.New("阿里云权限探测器未装载,不能确认域名授权")
|
||||
}
|
||||
|
||||
credentials, err := s.readDNSCredentials(ctx, credentialRef)
|
||||
if err != nil || len(credentials) == 0 {
|
||||
return nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取阿里云 DNS 凭据失败,不能确认域名授权:%w", err)
|
||||
}
|
||||
if _, err := dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials); err != nil {
|
||||
return fmt.Errorf("阿里云 DNS 凭据无效,不能确认域名授权:%w", err)
|
||||
}
|
||||
|
||||
if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil {
|
||||
@ -193,4 +202,4 @@ func (s *Server) readDNSCredentials(ctx context.Context, credentialRef string) (
|
||||
return nil, errors.New("凭据存储未就绪")
|
||||
}
|
||||
return reader.Get(ctx, credentialRef)
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,4 +165,57 @@ func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) {
|
||||
t.Fatalf("阻断提示应包含针对该域名的 RAM 授权指导,got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunDomainAuthorizationFailsClosed(t *testing.T) {
|
||||
for _, scenario := range []string{"checker_missing", "reference_missing", "store_missing", "secret_missing", "secret_empty", "secret_incomplete", "setup_incomplete"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
cfg := readyTestConfig(t.TempDir())
|
||||
cfg.DNS.Provider = dnsprovider.Aliyun
|
||||
cfg.DNS.CredentialRef = "secret://dns/test"
|
||||
store := &fakeSecretReaderStore{data: map[string]map[string]string{
|
||||
cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"},
|
||||
}}
|
||||
deps := Dependencies{Config: cfg, Secrets: store, AliyunChecker: &fakeAliyunScopeChecker{allowedZones: []string{"example.com"}}}
|
||||
switch scenario {
|
||||
case "checker_missing":
|
||||
deps.AliyunChecker = nil
|
||||
case "reference_missing":
|
||||
deps.Config.DNS.CredentialRef = ""
|
||||
case "store_missing":
|
||||
deps.Secrets = nil
|
||||
case "secret_missing":
|
||||
store.data = nil
|
||||
case "secret_empty":
|
||||
store.data[cfg.DNS.CredentialRef] = nil
|
||||
case "secret_incomplete":
|
||||
store.data[cfg.DNS.CredentialRef] = map[string]string{"access_key_id": "test-id"}
|
||||
case "setup_incomplete":
|
||||
deps.Config.SetupReady = false
|
||||
}
|
||||
server := New(deps)
|
||||
if err := server.validateDomainAliyunAuthorization(t.Context(), "example.com"); err == nil {
|
||||
t.Fatal("授权检查依赖异常必须拒绝,不能当作成功")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunDomainAPIRejectsPartiallyUnauthorizedSAN(t *testing.T) {
|
||||
cfg := readyTestConfig(t.TempDir())
|
||||
cfg.DNS.Provider = dnsprovider.Aliyun
|
||||
cfg.DNS.CredentialRef = "secret://dns/test"
|
||||
server := New(Dependencies{
|
||||
Config: cfg, Repository: &stubDomainRepository{},
|
||||
Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{
|
||||
cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"},
|
||||
}},
|
||||
AliyunChecker: &fakeAliyunScopeChecker{allowedZones: []string{"example.com"}},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["example.org"]}`))
|
||||
serveAdminHTTP(server, w, req)
|
||||
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "example.org") {
|
||||
t.Fatalf("部分 SAN 未授权应整单拒绝:%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@ -463,6 +463,9 @@ func (s *Server) createDomain(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
for _, san := range item.SANs {
|
||||
if san == item.Name {
|
||||
continue
|
||||
}
|
||||
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
@ -507,6 +510,9 @@ func (s *Server) updateDomain(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
for _, san := range item.SANs {
|
||||
if san == item.Name {
|
||||
continue
|
||||
}
|
||||
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
@ -789,4 +795,4 @@ func (s *Server) writeError(c *gin.Context, status int, message string, err erro
|
||||
s.logger.Error(message, "error", err)
|
||||
}
|
||||
c.JSON(status, gin.H{"error": message})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user