In this example we are going to use a SFTP client and server to upload/download files over a SSH connection. For the server simulation we will create a Docker container which provides basic functionality to interact with the server.


Important: Prefer next version over the this version where file is read into memory in small chunks as it is memory efficient.


Structure


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

Files


docker-compose.yaml


The private key authentication requires copying SSH keys. Depending on your SSH setup, use $ cp ~/.ssh/[id_rsa|id_ed25519] ssh/ command before running the docker command. I have id_rsa file hence reason using it.


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


Behind the scene it establishes a new SSH connection with a remote server that powers the SFTP client. Due to lack of built-in "keepalive" and auto reconnect options, every call to Upload, Download and Info functions open up a new SSH connection only if it was closed otherwise reuses live one. It currently leverages "password" and "private key" authentication methods which are configured with the Config type. If both enabled at same time, the private key authentication takes precedence.


This package needs two particular optimisations. For example Upload and Download can accept/return io interface or byte streams.


package sftp

import (
"fmt"
"io/ioutil"
"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
}

// Upload writes a file to a remote location.
func (c *Client) Upload(filePath string, fileContent []byte) error {
if err := c.connect(); err != nil {
return fmt.Errorf("connect: %w", err)
}

file, err := c.sftpClient.Create(filePath)
if err != nil {
return fmt.Errorf("file create: %w", err)
}
defer file.Close()

if _, err := file.Write(fileContent); err != nil {
return fmt.Errorf("file write: %w", err)
}

return nil
}

// Download returns a remote file.
func (c *Client) Download(filePath string) ([]byte, error) {
if err := c.connect(); err != nil {
return nil, fmt.Errorf("connect: %w", err)
}

file, err := c.sftpClient.Open(filePath)
if err != nil {
return nil, fmt.Errorf("file open: %w", err)
}
defer file.Close()

return ioutil.ReadAll(file)
}

// 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"
"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()

if err := client.Upload("tmp/file.txt", []byte(`File Content`)); err != nil {
log.Fatalln(err)
}

file, err := client.Download("tmp/file.txt")
if err != nil {
log.Fatalln(err)
}
fmt.Println(string(file))

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

Test


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.

Run


$ 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