Scert/backend/internal/deployment/ssh.go

296 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)