Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .build-tools/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module github.com/dapr/components-contrib/build-tools

go 1.24.4
go 1.24.6

toolchain go1.24.10

require (
github.com/dapr/components-contrib v0.0.0
Expand Down
255 changes: 255 additions & 0 deletions bindings/sftp/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/*
Copyright 2025 The Dapr Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package sftp

import (
"errors"
"fmt"
"os"
"sync"
"sync/atomic"

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

"github.com/dapr/kit/logger"
)

type Client struct {
sshClient *ssh.Client
sftpClient *sftpClient.Client
address string
config *ssh.ClientConfig
lock sync.RWMutex
needsReconnect atomic.Bool
log logger.Logger
}

func newClient(address string, config *ssh.ClientConfig, log logger.Logger) (*Client, error) {
if address == "" || config == nil {
return nil, errors.New("sftp binding error: client not initialized")
}

sshClient, err := newSSHClient(address, config)
if err != nil {
return nil, err
}

newSftpClient, err := sftpClient.NewClient(sshClient)
if err != nil {
_ = sshClient.Close()
return nil, fmt.Errorf("sftp binding error: error create sftp client: %w", err)
}

return &Client{
sshClient: sshClient,
sftpClient: newSftpClient,
address: address,
config: config,
log: log,
}, nil
}

func (c *Client) Close() error {
c.lock.Lock()
defer c.lock.Unlock()

// Close SFTP first, then SSH
var sftpErr, sshErr error
if c.sftpClient != nil {
sftpErr = c.sftpClient.Close()
}
if c.sshClient != nil {
sshErr = c.sshClient.Close()
}

// Return the first error encountered
if sftpErr != nil {
return sftpErr
}
return sshErr
}

func (c *Client) list(path string) ([]os.FileInfo, error) {
var fi []os.FileInfo

fn := func() error {
var err error
fi, err = c.sftpClient.ReadDir(path)
return err
}

err := c.withReconnection(fn)
if err != nil {
return nil, err
}

return fi, nil
}

func (c *Client) create(path string) (*sftpClient.File, string, error) {
dir, fileName := sftpClient.Split(path)

var file *sftpClient.File

createFn := func() error {
cErr := c.sftpClient.MkdirAll(dir)
if cErr != nil {
return cErr
}

file, cErr = c.sftpClient.Create(path)
if cErr != nil {
return cErr
}

return nil
}

rErr := c.withReconnection(createFn)
if rErr != nil {
return nil, "", rErr
}

return file, fileName, nil
}

func (c *Client) get(path string) (*sftpClient.File, error) {
var f *sftpClient.File

fn := func() error {
var err error
f, err = c.sftpClient.Open(path)
return err
}

err := c.withReconnection(fn)
if err != nil {
return nil, err
}

return f, nil
}

func (c *Client) delete(path string) error {
fn := func() error {
return c.sftpClient.Remove(path)
}

err := c.withReconnection(fn)
if err != nil {
return err
}

return nil
}

func (c *Client) ping() error {
_, err := c.sftpClient.Getwd()
if err != nil {
return err
}
return nil
}

func (c *Client) withReconnection(fn func() error) error {
err := c.do(fn)
if !c.shouldReconnect(err) {
return err
}

c.log.Debugf("sftp binding error: %s", err)
c.needsReconnect.Store(true)

rErr := c.doReconnect()
if rErr != nil {
c.log.Debugf("sftp binding error: reconnect failed: %s", rErr)
return errors.Join(err, rErr)
}
c.log.Debugf("sftp binding: reconnected to %s", c.address)

c.log.Debugf("sftp binding: retrying operation")
return c.do(fn)
}

func (c *Client) do(fn func() error) error {
c.lock.RLock()
defer c.lock.RUnlock()
return fn()
}

func (c *Client) doReconnect() error {
c.lock.Lock()
defer c.lock.Unlock()

c.log.Debugf("sftp binding: reconnecting to %s", c.address)

if !c.needsReconnect.Load() {
return nil
}
Comment on lines +198 to +200
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move below lock


pErr := c.ping()
if pErr == nil {
c.needsReconnect.Store(false)
return nil
}

sshClient, err := newSSHClient(c.address, c.config)
if err != nil {
return err
}

newSftpClient, err := sftpClient.NewClient(sshClient)
if err != nil {
_ = sshClient.Close()
return fmt.Errorf("sftp binding error: error create sftp client: %w", err)
}

if c.sftpClient != nil {
_ = c.sftpClient.Close()
}
if c.sshClient != nil {
_ = c.sshClient.Close()
}

c.sftpClient = newSftpClient
c.sshClient = sshClient

c.needsReconnect.Store(false)
return nil
}

func newSSHClient(address string, config *ssh.ClientConfig) (*ssh.Client, error) {
sshClient, err := ssh.Dial("tcp", address, config)
if err != nil {
return nil, fmt.Errorf("sftp binding error: error dialing ssh server: %w", err)
}
return sshClient, nil
}

// shouldReconnect returns true if the error looks like a transport-level failure
func (c *Client) shouldReconnect(err error) bool {
if err == nil {
return false
}

// SFTP status errors that are logical, not connectivity (avoid reconnect)
if errors.Is(err, sftpClient.ErrSSHFxPermissionDenied) ||
errors.Is(err, sftpClient.ErrSSHFxNoSuchFile) ||
errors.Is(err, sftpClient.ErrSSHFxOpUnsupported) {
return false
}

return true
}
11 changes: 11 additions & 0 deletions bindings/sftp/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
services:
sftp:
image:
atmoz/sftp
environment:
- SFTP_USERS=foo:pass:1001:1001:upload
volumes:
- ./upload:/home/foo/upload
ports:
- "2222:22"

55 changes: 31 additions & 24 deletions bindings/sftp/sftp.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
/*
Copyright 2025 The Dapr Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package sftp

import (
Expand Down Expand Up @@ -25,9 +38,9 @@ const (

// Sftp is a binding for file operations on sftp server.
type Sftp struct {
metadata *sftpMetadata
logger logger.Logger
sftpClient *sftpClient.Client
metadata *sftpMetadata
logger logger.Logger
c *Client
}

// sftpMetadata defines the sftp metadata.
Expand Down Expand Up @@ -115,19 +128,12 @@ func (sftp *Sftp) Init(_ context.Context, metadata bindings.Metadata) error {
HostKeyCallback: hostKeyCallback,
}

sshClient, err := ssh.Dial("tcp", m.Address, config)
if err != nil {
return fmt.Errorf("sftp binding error: error create ssh client: %w", err)
}

newSftpClient, err := sftpClient.NewClient(sshClient)
sftp.metadata = m
sftp.c, err = newClient(m.Address, config, sftp.logger)
if err != nil {
return fmt.Errorf("sftp binding error: error create sftp client: %w", err)
return fmt.Errorf("sftp binding error: create sftp client error: %w", err)
}

sftp.metadata = m
sftp.sftpClient = newSftpClient

return nil
}

Expand Down Expand Up @@ -161,14 +167,9 @@ func (sftp *Sftp) create(_ context.Context, req *bindings.InvokeRequest) (*bindi
return nil, fmt.Errorf("sftp binding error: %w", err)
}

dir, fileName := sftpClient.Split(path)
c := sftp.c

err = sftp.sftpClient.MkdirAll(dir)
if err != nil {
return nil, fmt.Errorf("sftp binding error: error create dir %s: %w", dir, err)
}

file, err := sftp.sftpClient.Create(path)
file, fileName, err := c.create(path)
if err != nil {
return nil, fmt.Errorf("sftp binding error: error create file %s: %w", path, err)
}
Expand Down Expand Up @@ -211,7 +212,9 @@ func (sftp *Sftp) list(_ context.Context, req *bindings.InvokeRequest) (*binding
return nil, fmt.Errorf("sftp binding error: %w", err)
}

files, err := sftp.sftpClient.ReadDir(path)
c := sftp.c

files, err := c.list(path)
if err != nil {
return nil, fmt.Errorf("sftp binding error: error read dir %s: %w", path, err)
}
Expand Down Expand Up @@ -246,7 +249,9 @@ func (sftp *Sftp) get(_ context.Context, req *bindings.InvokeRequest) (*bindings
return nil, fmt.Errorf("sftp binding error: %w", err)
}

file, err := sftp.sftpClient.Open(path)
c := sftp.c

file, err := c.get(path)
if err != nil {
return nil, fmt.Errorf("sftp binding error: error open file %s: %w", path, err)
}
Expand All @@ -272,7 +277,9 @@ func (sftp *Sftp) delete(_ context.Context, req *bindings.InvokeRequest) (*bindi
return nil, fmt.Errorf("sftp binding error: %w", err)
}

err = sftp.sftpClient.Remove(path)
c := sftp.c

err = c.delete(path)
if err != nil {
return nil, fmt.Errorf("sftp binding error: error remove file %s: %w", path, err)
}
Expand All @@ -296,7 +303,7 @@ func (sftp *Sftp) Invoke(ctx context.Context, req *bindings.InvokeRequest) (*bin
}

func (sftp *Sftp) Close() error {
return sftp.sftpClient.Close()
return sftp.c.Close()
}

func (metadata sftpMetadata) getPath(requestMetadata map[string]string) (path string, err error) {
Expand Down
Loading
Loading