296 lines
8.1 KiB
Go
296 lines
8.1 KiB
Go
package deployment
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/pkg/sftp"
|
||
"golang.org/x/crypto/ssh"
|
||
)
|
||
|
||
type SecretReader interface {
|
||
Get(ctx context.Context, reference string) (map[string]string, error)
|
||
}
|
||
|
||
type SSHFactory struct {
|
||
Secrets SecretReader
|
||
Timeout time.Duration
|
||
HostKeyAlgorithms []string
|
||
}
|
||
|
||
func (f SSHFactory) Connect(ctx context.Context, server Server) (Client, error) {
|
||
server = server.Normalize()
|
||
if err := server.Validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
if f.Secrets == nil {
|
||
return nil, errors.New("SSH 敏感信息存储未初始化")
|
||
}
|
||
secrets, err := f.Secrets.Get(ctx, server.PrivateKeyRef)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 SSH 私钥失败:%w", err)
|
||
}
|
||
privateKey := strings.TrimSpace(secrets["private_key"])
|
||
if privateKey == "" {
|
||
return nil, errors.New("SSH 私钥内容不能为空")
|
||
}
|
||
var signer ssh.Signer
|
||
passphrase := secrets["passphrase"]
|
||
if passphrase != "" {
|
||
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(passphrase))
|
||
} else {
|
||
signer, err = ssh.ParsePrivateKey([]byte(privateKey))
|
||
}
|
||
if err != nil {
|
||
return nil, errors.New("解析 SSH 私钥失败:私钥格式或口令无效")
|
||
}
|
||
timeout := f.Timeout
|
||
if timeout <= 0 {
|
||
timeout = 15 * time.Second
|
||
}
|
||
algorithms, err := f.secureHostKeyAlgorithms()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
timeoutContext, cancel := context.WithTimeout(ctx, timeout)
|
||
defer cancel()
|
||
sshClient, err := connectSSHClient(timeoutContext, server, signer, algorithms)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sftpClient, err := sftp.NewClient(sshClient)
|
||
if err != nil {
|
||
_ = sshClient.Close()
|
||
return nil, fmt.Errorf("创建 SFTP 客户端失败:%w", err)
|
||
}
|
||
return &sshClientAdapter{ssh: sshClient, sftp: sftpClient}, nil
|
||
}
|
||
|
||
func (f SSHFactory) secureHostKeyAlgorithms() ([]string, error) {
|
||
supported := ssh.SupportedAlgorithms().HostKeys
|
||
if len(f.HostKeyAlgorithms) == 0 {
|
||
return append([]string(nil), supported...), nil
|
||
}
|
||
supportedSet := make(map[string]struct{}, len(supported))
|
||
for _, algorithm := range supported {
|
||
supportedSet[algorithm] = struct{}{}
|
||
}
|
||
algorithms := make([]string, 0, len(f.HostKeyAlgorithms))
|
||
seen := make(map[string]struct{}, len(f.HostKeyAlgorithms))
|
||
for _, algorithm := range f.HostKeyAlgorithms {
|
||
algorithm = strings.TrimSpace(algorithm)
|
||
if _, ok := supportedSet[algorithm]; !ok {
|
||
continue
|
||
}
|
||
if _, ok := seen[algorithm]; ok {
|
||
continue
|
||
}
|
||
seen[algorithm] = struct{}{}
|
||
algorithms = append(algorithms, algorithm)
|
||
}
|
||
if len(algorithms) == 0 {
|
||
return nil, errors.New("SSH 主机密钥算法列表不包含受支持的安全算法")
|
||
}
|
||
return algorithms, nil
|
||
}
|
||
|
||
func connectSSHClient(
|
||
ctx context.Context,
|
||
server Server,
|
||
signer ssh.Signer,
|
||
algorithms []string,
|
||
) (*ssh.Client, error) {
|
||
address := net.JoinHostPort(server.Address, fmt.Sprintf("%d", server.Port))
|
||
var mismatchErrors []string
|
||
for _, algorithm := range algorithms {
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, fmt.Errorf("连接 SSH 服务器超时:%w", err)
|
||
}
|
||
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("连接 SSH 服务器失败:%w", err)
|
||
}
|
||
if deadline, ok := ctx.Deadline(); ok {
|
||
if err := connection.SetDeadline(deadline); err != nil {
|
||
_ = connection.Close()
|
||
return nil, fmt.Errorf("设置 SSH 连接超时失败:%w", err)
|
||
}
|
||
}
|
||
hostKeyMatched := false
|
||
hostKeyMismatch := ""
|
||
config := &ssh.ClientConfig{
|
||
User: server.Username,
|
||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||
HostKeyAlgorithms: []string{algorithm},
|
||
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||
fingerprint := ssh.FingerprintSHA256(key)
|
||
if fingerprint != server.HostKeyFingerprint {
|
||
hostKeyMismatch = fmt.Sprintf("%s 返回 %s", algorithm, fingerprint)
|
||
return fmt.Errorf("SSH 主机指纹不匹配:服务器 %s %s", server.Name, hostKeyMismatch)
|
||
}
|
||
hostKeyMatched = true
|
||
return nil
|
||
},
|
||
}
|
||
clientConn, channels, requests, handshakeErr := ssh.NewClientConn(connection, address, config)
|
||
if handshakeErr == nil {
|
||
if err := connection.SetDeadline(time.Time{}); err != nil {
|
||
_ = clientConn.Close()
|
||
return nil, fmt.Errorf("清除 SSH 连接超时失败:%w", err)
|
||
}
|
||
return ssh.NewClient(clientConn, channels, requests), nil
|
||
}
|
||
_ = connection.Close()
|
||
if hostKeyMatched {
|
||
return nil, fmt.Errorf("SSH 握手失败:%w", handshakeErr)
|
||
}
|
||
if hostKeyMismatch != "" {
|
||
mismatchErrors = append(mismatchErrors, hostKeyMismatch)
|
||
continue
|
||
}
|
||
var negotiationError *ssh.AlgorithmNegotiationError
|
||
if errors.As(handshakeErr, &negotiationError) && negotiationError.What == "host key" {
|
||
continue
|
||
}
|
||
return nil, fmt.Errorf("SSH 握手失败:%w", handshakeErr)
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, fmt.Errorf("连接 SSH 服务器超时:%w", err)
|
||
}
|
||
if len(mismatchErrors) == 0 {
|
||
return nil, fmt.Errorf("SSH 主机指纹不匹配:服务器 %s 未提供已登记的主机密钥", server.Name)
|
||
}
|
||
return nil, fmt.Errorf(
|
||
"SSH 主机指纹不匹配:服务器 %s 未提供已登记的主机密钥;已校验 %s",
|
||
server.Name,
|
||
strings.Join(mismatchErrors, ";"),
|
||
)
|
||
}
|
||
|
||
type sshClientAdapter struct {
|
||
ssh *ssh.Client
|
||
sftp *sftp.Client
|
||
}
|
||
|
||
func (c *sshClientAdapter) MkdirAll(ctx context.Context, path string) error {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
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
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
func (c *sshClientAdapter) Remove(ctx context.Context, path string) error {
|
||
if err := ctx.Err(); err != 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) {
|
||
if len(command) == 0 {
|
||
return CommandResult{}, errors.New("远程命令不能为空")
|
||
}
|
||
if err := validateCommand(command); err != nil {
|
||
return CommandResult{}, err
|
||
}
|
||
session, err := c.ssh.NewSession()
|
||
if err != nil {
|
||
return CommandResult{}, err
|
||
}
|
||
defer session.Close()
|
||
output, err := session.CombinedOutput(joinShellArgs(command))
|
||
if err != nil {
|
||
var exitError *ssh.ExitError
|
||
if errors.As(err, &exitError) {
|
||
return CommandResult{Output: output, ExitCode: exitError.ExitStatus()}, nil
|
||
}
|
||
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
|
||
}
|
||
}
|
||
if c.ssh != nil {
|
||
if err := c.ssh.Close(); err != nil && first == nil {
|
||
first = err
|
||
}
|
||
}
|
||
return first
|
||
}
|
||
|
||
func validateCommand(command []string) error {
|
||
for _, token := range command {
|
||
if strings.TrimSpace(token) == "" || strings.ContainsAny(token, "\x00\r\n") {
|
||
return errors.New("远程命令参数无效")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func joinShellArgs(command []string) string {
|
||
quoted := make([]string, len(command))
|
||
for index, token := range command {
|
||
quoted[index] = "'" + strings.ReplaceAll(token, "'", "'\\''") + "'"
|
||
}
|
||
return strings.Join(quoted, " ")
|
||
}
|
||
|
||
var _ io.Closer = (*sshClientAdapter)(nil)
|