diff --git a/bindings/sftp/README.md b/bindings/sftp/README.md new file mode 100644 index 0000000000..ea66e8a182 --- /dev/null +++ b/bindings/sftp/README.md @@ -0,0 +1,42 @@ +# SFTP with Toxiproxy Setup + +This setup includes an SFTP server and Toxiproxy for simulating network conditions during testing. + +## Services + +- **SFTP**: A simple SFTP server with a test user +- **Toxiproxy**: A TCP proxy that allows simulating network conditions like latency, bandwidth restrictions, and connection failures + +## Getting Started + +1. Start the services: + +```bash +docker-compose up -d +``` + +2. Connect to SFTP via the Toxiproxy port: + +```bash +sftp -P 2222 foo@localhost +# Password: pass +``` + +3. Control Toxiproxy via its API (port 8474): + +```bash +# Add 1000ms latency to SFTP connections +curl -X POST -H "Content-Type: application/json" \ + http://localhost:8474/proxies/sftp/toxics \ + -d '{"type":"latency", "attributes":{"latency":1000, "jitter":0}}' + +# Simulate connection timeout +curl -X POST -H "Content-Type: application/json" \ + http://localhost:8474/proxies/sftp/toxics \ + -d '{"type":"timeout", "attributes":{"timeout":1000}}' + +# Remove all toxics +curl -X GET http://localhost:8474/proxies/sftp/toxics +``` + +See the [Toxiproxy documentation](https://github.com/Shopify/toxiproxy) for more information on available toxics and configuration options. diff --git a/bindings/sftp/docker-compose.yaml b/bindings/sftp/docker-compose.yaml new file mode 100644 index 0000000000..ef1ff4326b --- /dev/null +++ b/bindings/sftp/docker-compose.yaml @@ -0,0 +1,29 @@ +services: + toxiproxy: + image: ghcr.io/shopify/toxiproxy:2.5.0 + ports: + - "8474:8474" # Toxiproxy API + - "2223:2223" # Proxied SFTP port + environment: + - HOSTNAME=0.0.0.0 + command: ["-host", "0.0.0.0"] + depends_on: + - sftp + networks: + - sftp-network + + sftp: + image: + atmoz/sftp + environment: + - SFTP_USERS=foo:pass:1001:1001:upload + volumes: + - ./upload:/home/foo/upload + ports: + - "2222:22" + networks: + - sftp-network + +networks: + sftp-network: + driver: bridge \ No newline at end of file diff --git a/bindings/sftp/sftp.go b/bindings/sftp/sftp.go index 211cce85d3..b0aaeeac05 100644 --- a/bindings/sftp/sftp.go +++ b/bindings/sftp/sftp.go @@ -25,9 +25,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. @@ -115,19 +115,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) 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 } @@ -161,14 +154,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) } @@ -211,7 +199,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) } @@ -246,7 +236,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) } @@ -272,7 +264,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) } @@ -296,7 +290,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) { diff --git a/bindings/sftp/sftp_client.go b/bindings/sftp/sftp_client.go new file mode 100644 index 0000000000..bde033c419 --- /dev/null +++ b/bindings/sftp/sftp_client.go @@ -0,0 +1,241 @@ +package sftp + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + "sync" + + sftpClient "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +type Client struct { + sshClient *ssh.Client + sftpClient *sftpClient.Client + address string + config *ssh.ClientConfig + lock sync.RWMutex + rLock sync.Mutex +} + +func newClient(address string, config *ssh.ClientConfig) (*Client, error) { + if address == "" || config == nil { + return nil, errors.New("sftp binding error: client not initialized") + } + + sshClient, err := ssh.Dial("tcp", address, config) + if err != nil { + return nil, fmt.Errorf("sftp binding error: error create ssh client: %w", 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, + }, nil +} + +func (c *Client) Close() error { + _ = c.sshClient.Close() + c.lock.Lock() + defer c.lock.Unlock() + return c.sftpClient.Close() +} + +func (c *Client) list(path string) ([]os.FileInfo, error) { + var fi []os.FileInfo + + fn := func() error { + var err error + c.lock.RLock() + defer c.lock.RUnlock() + fi, err = c.sftpClient.ReadDir(path) + return err + } + + err := withReconnection(c, 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 { + c.lock.RLock() + defer c.lock.RUnlock() + cErr := c.sftpClient.MkdirAll(dir) + if cErr != nil { + return fmt.Errorf("sftp binding error: error create dir %s: %w", dir, cErr) + } + + file, cErr = c.sftpClient.Create(path) + if cErr != nil { + return fmt.Errorf("sftp binding error: error create file %s: %w", path, cErr) + } + + return nil + } + + rErr := withReconnection(c, 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 + c.lock.RLock() + defer c.lock.RUnlock() + f, err = c.sftpClient.Open(path) + return err + } + + err := withReconnection(c, fn) + if err != nil { + return nil, err + } + + return f, nil +} + +func (c *Client) delete(path string) error { + fn := func() error { + var err error + c.lock.RLock() + defer c.lock.RUnlock() + err = c.sftpClient.Remove(path) + return err + } + + err := withReconnection(c, fn) + if err != nil { + return err + } + + return nil +} + +func (c *Client) ping() error { + c.lock.RLock() + defer c.lock.RUnlock() + _, err := c.sftpClient.Getwd() + if err != nil { + return err + } + return nil +} + +func withReconnection(c *Client, fn func() error) error { + err := fn() + if err == nil { + return nil + } + + if !shouldReconnect(err) { + return err + } + + rErr := doReconnect(c) + if rErr != nil { + return errors.Join(err, rErr) + } + + err = fn() + if err != nil { + return err + } + + return nil +} + +func doReconnect(c *Client) error { + c.rLock.Lock() + defer c.rLock.Unlock() + + err := c.ping() + if !shouldReconnect(err) { + return nil + } + + sshClient, err := ssh.Dial("tcp", c.address, c.config) + if err != nil { + return fmt.Errorf("sftp binding error: error create ssh client: %w", err) + } + + newSftpClient, err := sftpClient.NewClient(sshClient) + if err != nil { + _ = sshClient.Close() + return fmt.Errorf("sftp binding error: error create sftp client: %w", err) + } + + // Swap under short lock; close old clients after unlocking. + c.lock.Lock() + oldSftp := c.sftpClient + oldSSH := c.sshClient + c.sftpClient = newSftpClient + c.sshClient = sshClient + c.lock.Unlock() + + if oldSftp != nil { + _ = oldSftp.Close() + } + if oldSSH != nil { + _ = oldSSH.Close() + } + + return nil +} + +// shouldReconnect returns true if the error looks like a transport-level failure +func shouldReconnect(err error) bool { + if err == nil { + return false + } + + // Network/timeout conditions + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, os.ErrDeadlineExceeded) { + return true + } + + // Common wrapped network error messages + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "use of closed network connection"), + strings.Contains(msg, "connection reset by peer"), + strings.Contains(msg, "broken pipe"), + strings.Contains(msg, "connection refused"), + strings.Contains(msg, "network is unreachable"), + strings.Contains(msg, "no such host"): + return true + } + + // 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 +} diff --git a/bindings/sftp/sftp_integration_test.go b/bindings/sftp/sftp_integration_test.go index 178c7535db..819f64d1ed 100644 --- a/bindings/sftp/sftp_integration_test.go +++ b/bindings/sftp/sftp_integration_test.go @@ -5,29 +5,37 @@ import ( "os" "testing" + toxiproxy "github.com/Shopify/toxiproxy/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/dapr/components-contrib/bindings" ) +const toxyProxySftp = "0.0.0.0:2223" + var connectionStringEnvKey = "DAPR_TEST_SFTP_CONNSTRING" // Run docker from the file location as the upload folder is relative to the test -// docker run -v ./upload:/home/foo/upload -p 2222:22 -d atmoz/sftp foo:pass:1001 +// cd integration +// docker-compose up -d +// export DAPR_TEST_SFTP_CONNSTRING=sftp:22 func TestIntegrationCases(t *testing.T) { connectionString := os.Getenv(connectionStringEnvKey) if connectionString == "" { - t.Skipf(`sftp binding integration tests skipped. To enable this test, define the connection string using environment variable '%[1]s' (example 'export %[1]s="localhost:2222")'`, connectionStringEnvKey) + t.Skipf("sftp binding integration skipped. To enable this test, define the connection string using environment variable '%[1]s' (example 'export %[1]s=\"sftp:22\")'", connectionStringEnvKey) } t.Run("List operation", testListOperation) t.Run("Create operation", testCreateOperation) + t.Run("Reconnections", testReconnect) } func testListOperation(t *testing.T) { c := Sftp{} + m := bindings.Metadata{} + m.Properties = map[string]string{ "rootPath": "/upload", "address": os.Getenv(connectionStringEnvKey), @@ -35,6 +43,7 @@ func testListOperation(t *testing.T) { "password": "pass", "insecureIgnoreHostKey": "true", } + err := c.Init(t.Context(), m) require.NoError(t, err) @@ -80,3 +89,44 @@ func testCreateOperation(t *testing.T) { require.NoError(t, err) assert.Equal(t, "test.txt", file.Name()) } + +func testReconnect(t *testing.T) { + c := Sftp{} + + client := toxiproxy.NewClient("localhost:8474") + + p, err := client.CreateProxy("sftp", toxyProxySftp, "sftp:22") + require.NoError(t, err) + defer p.Delete() + + m := bindings.Metadata{} + + m.Properties = map[string]string{ + "rootPath": "/upload", + "address": toxyProxySftp, + "username": "foo", + "password": "pass", + "insecureIgnoreHostKey": "true", + } + + err = c.Init(t.Context(), m) + require.NoError(t, err) + + r, err := c.Invoke(t.Context(), &bindings.InvokeRequest{Operation: bindings.ListOperation}) + require.NoError(t, err) + assert.NotNil(t, r.Data) + + tx, err := p.AddToxic("reset", "reset_peer", "downstream", 1, toxiproxy.Attributes{}) + require.NoError(t, err) + + _, err = c.Invoke(t.Context(), &bindings.InvokeRequest{Operation: bindings.ListOperation}) + require.Error(t, err) + + _ = p.RemoveToxic(tx.Name) + r, err = c.Invoke(t.Context(), &bindings.InvokeRequest{Operation: bindings.ListOperation}) + require.NoError(t, err) + + var d []listResponse + err = json.Unmarshal(r.Data, &d) + require.NoError(t, err) +} diff --git a/bindings/sftp/upload/test.txt b/bindings/sftp/upload/test.txt new file mode 100644 index 0000000000..6d78b42a41 --- /dev/null +++ b/bindings/sftp/upload/test.txt @@ -0,0 +1 @@ +test data 1 \ No newline at end of file diff --git a/go.mod b/go.mod index 0d24d1cb13..1c164ff04c 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/Azure/go-amqp v1.0.5 github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/IBM/sarama v1.45.2 + github.com/Shopify/toxiproxy v2.1.4+incompatible github.com/aerospike/aerospike-client-go/v6 v6.12.0 github.com/alibaba/sentinel-golang v1.0.4 github.com/alibabacloud-go/darabonba-openapi v0.2.1 diff --git a/go.sum b/go.sum index 5a8bc06ef4..0e5bfdfd02 100644 --- a/go.sum +++ b/go.sum @@ -156,6 +156,7 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE github.com/RoaringBitmap/roaring v1.1.0 h1:b10lZrZXaY6Q6EKIRrmOF519FIyQQ5anPgGr3niw2yY= github.com/RoaringBitmap/roaring v1.1.0/go.mod h1:icnadbWcNyfEHlYdr+tDlOTih1Bf/h+rzPpv4sbomAA= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible h1:TKdv8HiTLgE5wdJuEML90aBgNWsokNbMijUGhmcoBJc= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=