Golang SFTP 客户端服务器实例:通过SSH连接上传和下载文件(流式)

1,732 阅读2分钟

多年来,我们一直投入大量的个人时间和精力,与大家分享我们的知识。然而,我们现在需要你的帮助来维持这个博客的运行。你所要做的就是点击网站上的一个广告,否则它将由于托管等费用而不幸被关闭。谢谢你。

在这个例子中,我们将使用一个SFTP客户端和服务器,通过SSH连接上传/下载文件。对于服务器的模拟,我们将创建一个Docker容器,提供与服务器交互的基本功能。

**重要提示:**比起之前将文件读入内存的版本,这个版本更适合,因为它的内存效率不高。

结构

├── docker
│   └── docker-compose.yaml
├── main.go
├── file.txt
├── sftp
│   └── sftp.go
├── ssh
│   └── id_rsa
└── tmp

文件

docker-compose.yaml

私钥认证需要复制SSH密钥。根据你的SSH设置,在运行docker命令之前使用$ cp ~/.ssh/[id_rsa|id_ed25519] ssh/ 命令。我有id_rsa 文件,因此使用它的原因。

version: "3.4"
services:
  sftp-server:
    image: "atmoz/sftp"
    container_name: "sftp-server"
    ports:
      - "2022:22"
    volumes:
      - "../tmp:/home/inanzzz/tmp"
      - "$HOME/.ssh/id_rsa:/etc/ssh/ssh_host_rsa_key:ro"
      - "$HOME/.ssh/id_rsa.pub:/home/inanzzz/.ssh/keys/id_rsa.pub:ro"
      # - "$HOME/.ssh/id_ed25519:/etc/ssh/ssh_host_ed25519_key:ro"
      # - "$HOME/.ssh/id_ed25519.pub:/home/inanzzz/.ssh/keys/id_ed25519.pub:ro"
    command: "inanzzz:password:1001"

sftp.go

在幕后,它与远程服务器建立了一个新的SSH连接,为SFTP客户端提供动力。由于缺乏内置的 "keepalive "和自动重新连接选项,每次调用Upload,DownloadInfo 函数时,只有当它被关闭时才会打开一个新的SSH连接。它目前利用 "密码 "和 "私钥 "认证方法,这些方法是通过Config 类型配置的。如果同时启用这两种方法,则以私钥认证为准。

package sftp

import (
	"fmt"
	"io"
	"net"
	"os"
	"time"

	"github.com/pkg/sftp"
	"golang.org/x/crypto/ssh"
)

// Config represents SSH connection parameters.
type Config struct {
	Username     string
	Password     string
	PrivateKey   string
	Server       string
	KeyExchanges []string

	Timeout time.Duration
}

// Client provides basic functionality to interact with a SFTP server.
type Client struct {
	config     Config
	sshClient  *ssh.Client
	sftpClient *sftp.Client
}

// New initialises SSH and SFTP clients and returns Client type to use.
func New(config Config) (*Client, error) {
	c := &Client{
		config: config,
	}

	if err := c.connect(); err != nil {
		return nil, err
	}

	return c, nil
}

// Create creates a remote/destination file for I/O.
func (c *Client) Create(filePath string) (io.ReadWriteCloser, error) {
	if err := c.connect(); err != nil {
		return nil, fmt.Errorf("connect: %w", err)
	}

	return c.sftpClient.Create(filePath)
}

// Upload writes local/source file data streams to remote/destination file.
func (c *Client) Upload(source io.Reader, destination io.Writer, size int) error {
	if err := c.connect(); err != nil {
		return fmt.Errorf("connect: %w", err)
	}

	chunk := make([]byte, size)

	for {
		num, err := source.Read(chunk)
		if err == io.EOF {
			tot, err := destination.Write(chunk[:num])
			if err != nil {
				return err
			}

			if tot != len(chunk[:num]) {
				return fmt.Errorf("failed to write stream")
			}

			return nil
		}

		if err != nil {
			return err
		}

		tot, err := destination.Write(chunk[:num])
		if err != nil {
			return err
		}

		if tot != len(chunk[:num]) {
			return fmt.Errorf("failed to write stream")
		}
	}
}

// Download returns remote/destination file for reading.
func (c *Client) Download(filePath string) (io.ReadCloser, error) {
	if err := c.connect(); err != nil {
		return nil, fmt.Errorf("connect: %w", err)
	}

	return c.sftpClient.Open(filePath)
}

// Info gets the details of a file. If the file was not found, an error is returned.
func (c *Client) Info(filePath string) (os.FileInfo, error) {
	if err := c.connect(); err != nil {
		return nil, fmt.Errorf("connect: %w", err)
	}

	info, err := c.sftpClient.Lstat(filePath)
	if err != nil {
		return nil, fmt.Errorf("file stats: %w", err)
	}

	return info, nil
}

// Close closes open connections.
func (c *Client) Close() {
	if c.sftpClient != nil {
		c.sftpClient.Close()
	}
	if c.sshClient != nil {
		c.sshClient.Close()
	}
}

// connect initialises a new SSH and SFTP client only if they were not
// initialised before at all and, they were initialised but the SSH
// connection was lost for any reason.
func (c *Client) connect() error {
	if c.sshClient != nil {
		_, _, err := c.sshClient.SendRequest("keepalive", false, nil)
		if err == nil {
			return nil
		}
	}

	auth := ssh.Password(c.config.Password)
	if c.config.PrivateKey != "" {
		signer, err := ssh.ParsePrivateKey([]byte(c.config.PrivateKey))
		if err != nil {
			return fmt.Errorf("ssh parse private key: %w", err)
		}
		auth = ssh.PublicKeys(signer)
	}

	cfg := &ssh.ClientConfig{
		User: c.config.Username,
		Auth: []ssh.AuthMethod{
			auth,
		},
		HostKeyCallback: func(string, net.Addr, ssh.PublicKey) error { return nil },
		Timeout:         c.config.Timeout,
		Config: ssh.Config{
			KeyExchanges: c.config.KeyExchanges,
		},
	}

	sshClient, err := ssh.Dial("tcp", c.config.Server, cfg)
	if err != nil {
		return fmt.Errorf("ssh dial: %w", err)
	}
	c.sshClient = sshClient

	sftpClient, err := sftp.NewClient(sshClient)
	if err != nil {
		return fmt.Errorf("sftp new client: %w", err)
	}
	c.sftpClient = sftpClient

	return nil
}

main.go

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"time"

	"github.com/you/client/sftp"
)

func main() {
	pk, err := ioutil.ReadFile("./ssh/id_rsa") // required only if private key authentication is to be used
	if err != nil {
		log.Fatalln(err)
	}

	config := sftp.Config{
		Username:     "inanzzz",
		Password:     "password", // required only if password authentication is to be used
		PrivateKey:   string(pk), // required only if private key authentication is to be used
		Server:       "0.0.0.0:2022",
		KeyExchanges: []string{"diffie-hellman-group-exchange-sha256", "diffie-hellman-group14-sha256"}, // optional
		Timeout:      time.Second * 30,                                                                  // 0 for not timeout
	}

	client, err := sftp.New(config)
	if err != nil {
		log.Fatalln(err)
	}
	defer client.Close()

	// Open local file for reading.
	source, err := os.Open("file.txt")
	if err != nil {
		log.Fatalln(err)
	}
	defer source.Close()

	// Create remote file for writing.
	destination, err := client.Create("tmp/file.txt")
	if err != nil {
		log.Fatalln(err)
	}
	defer destination.Close()

	// Upload local file to a remote location as in 1MB (byte) chunks.
	if err := client.Upload(source, destination, 1000000); err != nil {
		log.Fatalln(err)
	}

	// Download remote file.
	file, err := client.Download("tmp/file.txt")
	if err != nil {
		log.Fatalln(err)
	}
	defer file.Close()

	// Read downloaded file.
	data, err := ioutil.ReadAll(file)
	if err != nil {
		log.Fatalln(err)
	}
	fmt.Println(string(data))

	// Get remote file stats.
	info, err := client.Info("tmp/file.txt")
	if err != nil {
		log.Fatalln(err)
	}
	fmt.Printf("%+v\n", info)
}

测试

Docker

$ docker-compose up

Recreating sftp-server ... done
Attaching to sftp-server
sftp-server    | [/usr/local/bin/create-sftp-user] Parsing user data: "inanzzz:password:1001"
sftp-server    | Generating public/private ed25519 key pair.
sftp-server    | Your identification has been saved in /etc/ssh/ssh_host_ed25519_key.
sftp-server    | Your public key has been saved in /etc/ssh/ssh_host_ed25519_key.pub.
sftp-server    | The key fingerprint is:
sftp-server    | SHA256:ggGrRmCvYias/aso5Lsad3POMerwIzys9fseI+4Zweghh root@111f66e033b4
sftp-server    | The keys randomart image is:
sftp-server    | +--[ED25519 256]--+
sftp-server    | |        o        |
sftp-server    | | .        o      |
sftp-server    | |o o      o .     |
sftp-server    | |=++o+o o +       |
sftp-server    | |+=*=+.. +        |
sftp-server    | |O*=o..o .        |
sftp-server    | +----[SHA256]-----+
sftp-server    | chmod: changing permissions of '/etc/ssh/ssh_host_rsa_key': Read-only file system
sftp-server    | [/entrypoint] Executing sshd
sftp-server    | Server listening on 0.0.0.0 port 22.
sftp-server    | Server listening on :: port 22.

运行

$ go run -race main.go
File Content
&{name:file.txt stat:0xc0001d01c0}
// When using private key auth
sftp-server    | Accepted publickey for inanzzz from 172.21.0.1 port 57818 ssh2: RSA SHA256:saboQipddaHVp67cUsadTSwS1ORslJioasasCA6NIDs
// When using password auth
sftp-server    | Accepted password for inanzzz from 172.21.0.1 port 57822 ssh2