373 lines
10 KiB
Go
373 lines
10 KiB
Go
package deployment
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/ed25519"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pkg/sftp"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
func TestSSHFactoryConnectsUsingRegisteredHostKeyAcrossAlgorithms(t *testing.T) {
|
|
ecdsaHostSigner := newTestECDSAHostSigner(t)
|
|
ed25519HostSigner, _ := newTestSSHSigner(t)
|
|
server := startTestPasswordSSHServerWithHandler(t, func(password string) error {
|
|
if password != "test-password" {
|
|
return errors.New("unexpected test password")
|
|
}
|
|
return nil
|
|
}, serveTestSFTP, ecdsaHostSigner, ed25519HostSigner)
|
|
|
|
factory := SSHFactory{
|
|
Secrets: staticSecretReader{
|
|
"secret://ssh/hostkey-fallback": {"password": "test-password"},
|
|
},
|
|
Timeout: 2 * time.Second,
|
|
HostKeyAlgorithms: []string{
|
|
ssh.KeyAlgoECDSA256,
|
|
ssh.KeyAlgoED25519,
|
|
},
|
|
}
|
|
client, err := factory.Connect(t.Context(), testSSHServerModel(
|
|
server,
|
|
"secret://ssh/hostkey-fallback",
|
|
ssh.FingerprintSHA256(ed25519HostSigner.PublicKey()),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("factory should use the registered ED25519 host key after the ECDSA mismatch: %v", err)
|
|
}
|
|
defer client.Close()
|
|
if server.accepted.Load() != 2 {
|
|
t.Fatalf("expected two host-key algorithm attempts, got %d", server.accepted.Load())
|
|
}
|
|
if exists, err := client.Exists(t.Context(), "."); err != nil || !exists {
|
|
t.Fatalf("SFTP connection is unavailable: exists=%t err=%v", exists, err)
|
|
}
|
|
}
|
|
|
|
func TestSSHFactorySkipsUnavailableSafeHostKeyAlgorithm(t *testing.T) {
|
|
ed25519HostSigner, _ := newTestSSHSigner(t)
|
|
server := startTestPasswordSSHServerWithHandler(t, func(password string) error {
|
|
if password != "test-password" {
|
|
return errors.New("unexpected test password")
|
|
}
|
|
return nil
|
|
}, serveTestSFTP, ed25519HostSigner)
|
|
|
|
factory := SSHFactory{
|
|
Secrets: staticSecretReader{
|
|
"secret://ssh/hostkey-unavailable": {"password": "test-password"},
|
|
},
|
|
Timeout: 2 * time.Second,
|
|
HostKeyAlgorithms: []string{
|
|
ssh.KeyAlgoECDSA256,
|
|
ssh.KeyAlgoED25519,
|
|
},
|
|
}
|
|
client, err := factory.Connect(t.Context(), testSSHServerModel(
|
|
server,
|
|
"secret://ssh/hostkey-unavailable",
|
|
ssh.FingerprintSHA256(ed25519HostSigner.PublicKey()),
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("unavailable safe host-key algorithm should be skipped: %v", err)
|
|
}
|
|
client.Close()
|
|
if server.accepted.Load() != 2 {
|
|
t.Fatalf("expected two host-key algorithm attempts, got %d", server.accepted.Load())
|
|
}
|
|
}
|
|
|
|
func TestSSHFactoryRejectsUnregisteredHostKeysAcrossAlgorithms(t *testing.T) {
|
|
ecdsaHostSigner := newTestECDSAHostSigner(t)
|
|
ed25519HostSigner, _ := newTestSSHSigner(t)
|
|
server := startTestPasswordSSHServerWithHandler(t, func(password string) error {
|
|
if password != "test-password" {
|
|
return errors.New("unexpected test password")
|
|
}
|
|
return nil
|
|
}, serveTestSFTP, ecdsaHostSigner, ed25519HostSigner)
|
|
|
|
factory := SSHFactory{
|
|
Secrets: staticSecretReader{
|
|
"secret://ssh/hostkey-reject": {"password": "test-password"},
|
|
},
|
|
Timeout: 2 * time.Second,
|
|
HostKeyAlgorithms: []string{
|
|
ssh.KeyAlgoECDSA256,
|
|
ssh.KeyAlgoED25519,
|
|
},
|
|
}
|
|
_, err := factory.Connect(t.Context(), testSSHServerModel(
|
|
server,
|
|
"secret://ssh/hostkey-reject",
|
|
"SHA256:unregistered-test-host-key",
|
|
))
|
|
if err == nil || !strings.Contains(err.Error(), "SSH") {
|
|
t.Fatalf("unregistered host key fingerprint must be rejected: %v", err)
|
|
}
|
|
if server.accepted.Load() != 2 {
|
|
t.Fatalf("expected two host-key algorithm attempts, got %d", server.accepted.Load())
|
|
}
|
|
}
|
|
|
|
func TestSSHFactoryStopsAfterMatchedHostKeyAuthenticationFailure(t *testing.T) {
|
|
ecdsaHostSigner := newTestECDSAHostSigner(t)
|
|
ed25519HostSigner, _ := newTestSSHSigner(t)
|
|
server := startTestPasswordSSHServerWithHandler(t, func(string) error {
|
|
return errors.New("test authentication rejected")
|
|
}, serveTestSFTP, ecdsaHostSigner, ed25519HostSigner)
|
|
|
|
factory := SSHFactory{
|
|
Secrets: staticSecretReader{
|
|
"secret://ssh/auth-reject": {"password": "test-password"},
|
|
},
|
|
Timeout: 2 * time.Second,
|
|
HostKeyAlgorithms: []string{
|
|
ssh.KeyAlgoECDSA256,
|
|
ssh.KeyAlgoED25519,
|
|
},
|
|
}
|
|
_, err := factory.Connect(t.Context(), testSSHServerModel(
|
|
server,
|
|
"secret://ssh/auth-reject",
|
|
ssh.FingerprintSHA256(ecdsaHostSigner.PublicKey()),
|
|
))
|
|
if err == nil || !strings.Contains(err.Error(), "SSH") {
|
|
t.Fatalf("authentication failure after a matched host key must return directly: %v", err)
|
|
}
|
|
if server.accepted.Load() != 1 {
|
|
t.Fatalf("authentication failure must not try another host-key algorithm, got %d attempts", server.accepted.Load())
|
|
}
|
|
}
|
|
func TestSSHFactoryDefaultHostKeyAlgorithmsExcludeCertificates(t *testing.T) {
|
|
algorithms, err := (SSHFactory{}).secureHostKeyAlgorithms()
|
|
if err != nil {
|
|
t.Fatalf("默认 SSH 主机密钥算法过滤失败:%v", err)
|
|
}
|
|
if len(algorithms) == 0 {
|
|
t.Fatal("默认 SSH 主机密钥算法不能为空")
|
|
}
|
|
for _, algorithm := range algorithms {
|
|
if strings.HasSuffix(algorithm, "-cert-v01@openssh.com") {
|
|
t.Fatalf("默认算法不应包含 OpenSSH 证书算法:%s", algorithm)
|
|
}
|
|
}
|
|
}
|
|
func TestSSHFactoryFiltersUnsupportedAndDuplicateHostKeyAlgorithms(t *testing.T) {
|
|
factory := SSHFactory{HostKeyAlgorithms: []string{
|
|
"ssh-dss",
|
|
ssh.KeyAlgoED25519,
|
|
ssh.KeyAlgoED25519,
|
|
}}
|
|
algorithms, err := factory.secureHostKeyAlgorithms()
|
|
if err != nil {
|
|
t.Fatalf("安全算法过滤失败:%v", err)
|
|
}
|
|
if len(algorithms) != 1 || algorithms[0] != ssh.KeyAlgoED25519 {
|
|
t.Fatalf("算法过滤结果不正确:%v", algorithms)
|
|
}
|
|
factory.HostKeyAlgorithms = []string{"ssh-dss"}
|
|
if _, err := factory.secureHostKeyAlgorithms(); err == nil {
|
|
t.Fatal("仅包含不安全或不支持算法时必须拒绝")
|
|
}
|
|
}
|
|
|
|
type testSSHServer struct {
|
|
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 {
|
|
config := &ssh.ServerConfig{
|
|
PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
|
return nil, authorize(key)
|
|
},
|
|
}
|
|
return startTestSSHServerWithConfig(t, config, handler, hostSigners...)
|
|
}
|
|
|
|
func startTestPasswordSSHServerWithHandler(
|
|
t *testing.T,
|
|
authorize func(string) error,
|
|
handler func(ssh.Channel, <-chan *ssh.Request),
|
|
hostSigners ...ssh.Signer,
|
|
) *testSSHServer {
|
|
config := &ssh.ServerConfig{
|
|
PasswordCallback: func(_ ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
|
return nil, authorize(string(password))
|
|
},
|
|
}
|
|
return startTestSSHServerWithConfig(t, config, handler, hostSigners...)
|
|
}
|
|
|
|
func startTestSSHServerWithConfig(
|
|
t *testing.T,
|
|
config *ssh.ServerConfig,
|
|
handler func(ssh.Channel, <-chan *ssh.Request),
|
|
hostSigners ...ssh.Signer,
|
|
) *testSSHServer {
|
|
t.Helper()
|
|
for _, signer := range hostSigners {
|
|
config.AddHostKey(signer)
|
|
}
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("failed to start test SSH server: %v", err)
|
|
}
|
|
host, portText, err := net.SplitHostPort(listener.Addr().String())
|
|
if err != nil {
|
|
listener.Close()
|
|
t.Fatalf("failed to parse test SSH address: %v", err)
|
|
}
|
|
port, err := strconv.Atoi(portText)
|
|
if err != nil {
|
|
listener.Close()
|
|
t.Fatalf("failed to parse test SSH port: %v", err)
|
|
}
|
|
server := &testSSHServer{
|
|
listener: listener,
|
|
host: host,
|
|
port: port,
|
|
config: config,
|
|
channelHandler: handler,
|
|
}
|
|
t.Cleanup(func() {
|
|
listener.Close()
|
|
})
|
|
go server.serve()
|
|
return server
|
|
}
|
|
func (s *testSSHServer) serve() {
|
|
for {
|
|
connection, err := s.listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.accepted.Add(1)
|
|
go s.serveConnection(connection)
|
|
}
|
|
}
|
|
|
|
func (s *testSSHServer) serveConnection(connection net.Conn) {
|
|
defer connection.Close()
|
|
_, channels, requests, err := ssh.NewServerConn(connection, s.config)
|
|
if err != nil {
|
|
return
|
|
}
|
|
go ssh.DiscardRequests(requests)
|
|
for channelRequest := range channels {
|
|
if channelRequest.ChannelType() != "session" {
|
|
channelRequest.Reject(ssh.UnknownChannelType, "session channel required")
|
|
continue
|
|
}
|
|
channel, requests, err := channelRequest.Accept()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
go s.channelHandler(channel, requests)
|
|
}
|
|
}
|
|
|
|
func serveTestSFTP(channel ssh.Channel, requests <-chan *ssh.Request) {
|
|
defer channel.Close()
|
|
for request := range requests {
|
|
if request.Type != "subsystem" {
|
|
request.Reply(false, nil)
|
|
continue
|
|
}
|
|
var subsystem struct {
|
|
Name string
|
|
}
|
|
if err := ssh.Unmarshal(request.Payload, &subsystem); err != nil || subsystem.Name != "sftp" {
|
|
request.Reply(false, nil)
|
|
continue
|
|
}
|
|
if err := request.Reply(true, nil); err != nil {
|
|
return
|
|
}
|
|
server, err := sftp.NewServer(channel)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = server.Serve()
|
|
_ = server.Close()
|
|
return
|
|
}
|
|
}
|
|
|
|
func newTestSSHSigner(t *testing.T) (ssh.Signer, string) {
|
|
t.Helper()
|
|
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("生成测试 ED25519 密钥失败:%v", err)
|
|
}
|
|
signer, err := ssh.NewSignerFromKey(privateKey)
|
|
if err != nil {
|
|
t.Fatalf("创建测试 SSH 签名器失败:%v", err)
|
|
}
|
|
block, err := ssh.MarshalPrivateKey(privateKey, "scert-ssh-test")
|
|
if err != nil {
|
|
t.Fatalf("编码测试 SSH 私钥失败:%v", err)
|
|
}
|
|
return signer, string(pem.EncodeToMemory(block))
|
|
}
|
|
|
|
func newTestECDSAHostSigner(t *testing.T) ssh.Signer {
|
|
t.Helper()
|
|
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("生成测试 ECDSA 主机密钥失败:%v", err)
|
|
}
|
|
signer, err := ssh.NewSignerFromKey(privateKey)
|
|
if err != nil {
|
|
t.Fatalf("创建测试 ECDSA 主机签名器失败:%v", err)
|
|
}
|
|
return signer
|
|
}
|
|
|
|
func testSSHServerModel(server *testSSHServer, privateKeyRef, fingerprint string) Server {
|
|
return Server{
|
|
ID: fmt.Sprintf("server-%d", server.port),
|
|
Name: "多主机密钥测试服务器",
|
|
Address: server.host,
|
|
Port: server.port,
|
|
Username: "deploy",
|
|
AuthMethod: SSHAuthPassword,
|
|
PasswordRef: privateKeyRef,
|
|
HostKeyFingerprint: fingerprint,
|
|
CertificatePath: "/tmp/scert-test-cert.pem",
|
|
PrivateKeyPath: "/tmp/scert-test-key.pem",
|
|
CertificateChainPath: "/tmp/scert-test-fullchain.pem",
|
|
WebServer: WebServerNginx,
|
|
}
|
|
}
|