286 lines
8.6 KiB
Go
286 lines
8.6 KiB
Go
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 握手取消不及时")
|
|
}
|
|
}
|
|
|
|
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) {
|
|
<-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("远程输出未限制内存占用")
|
|
}
|
|
}
|