diff --git a/.golangci.yml b/.golangci.yml
index 49df33a..2ebd00d 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -277,6 +277,7 @@ linters:
- ^github.com/urfave/cli.v3.ArgumentBase$
- ^github.com/urfave/cli.v3.Command$
- ^github.com/urfave/cli.v3.FlagBase$
+ - ^golang.org/x/crypto/ssh.+Config$
- ^golang.org/x/tools/go/analysis.Analyzer$
- ^google.golang.org/protobuf/.+Options$
- ^gopkg.in/telebot.v4.LongPoller$
diff --git a/README.md b/README.md
index 5875b85..0e81941 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@
sftp-sync
- A command-line utility for syncing a local folder with a remote FTP server on every change of files or directories.
+ A command-line utility for syncing a local folder with a remote FTP or SFTP server on every change of files or directories.
Report Bug
@@ -78,13 +78,14 @@
-sftp-sync is a command-line utility for syncing a local folder with a remote FTP server on every change of files or directories.
+sftp-sync is a command-line utility for syncing a local folder with a remote FTP or SFTP server on every change of files or directories.
### Features
-- Continuous synchronization: Automatically syncs local changes to the remote FTP server whenever files or directories are added, modified, or deleted.
+- Continuous synchronization: Automatically syncs local changes to the remote FTP or SFTP server whenever files or directories are added, modified, or deleted.
- Exclude paths: Allows you to exclude specific paths from being synced.
- Easy to use: Simple and intuitive command-line interface.
+- Protocol support: Supports both FTP and SFTP (SSH File Transfer Protocol).
(back to top)
@@ -104,8 +105,8 @@ sftp-sync is a command-line utility for syncing a local folder with a remote FTP
### Prerequisites
-- Go 1.24.3 or higher installed on your system
-- Access to an FTP server with valid credentials
+- Go 1.25.0 or higher installed on your system
+- Access to an (S)FTP server with valid credentials
### Installation Methods
@@ -153,11 +154,32 @@ The binary will be available in the `bin/` directory.
## Usage
Run the `sftp-sync` command with the necessary options and arguments:
+**FTP:**
```shell
sftp-sync --dest=ftp://username:password@hostname:port/path/to/remote/folder \
--exclude=.git /path/to/local/folder
```
+**SFTP (password):**
+```shell
+sftp-sync --dest=sftp://username:password@hostname:22/path/to/remote/folder \
+ --exclude=.git /path/to/local/folder
+```
+
+**SFTP (SSH key):**
+```shell
+sftp-sync --dest="sftp://username@hostname:22/path/to/remote/folder?key=~/.ssh/id_ed25519" \
+ --exclude=.git /path/to/local/folder
+```
+
+**SFTP (SSH agent):**
+```shell
+sftp-sync --dest="sftp://username@hostname:22/path/to/remote/folder?agent=true" \
+ --exclude=.git /path/to/local/folder
+```
+
+> **Note:** SFTP uses SSH port 22 by default (vs FTP port 21).
+
### Environment Variables
- `DEBUG`: When set to any value, enables debug mode (equivalent to `--debug` flag).
@@ -169,7 +191,28 @@ sftp-sync --dest=ftp://username:password@hostname:port/path/to/remote/folder \
### Sync Command Options
-- `--dest`: The destination FTP server URL. It should follow the format `ftp://username:password@hostname:port/path/to/remote/folder`.
+- `--dest`: The destination server URL. Supports both FTP and SFTP:
+ - FTP: `ftp://username:password@hostname:port/path/to/remote/folder`
+ - SFTP (password): `sftp://username:password@hostname:22/path/to/remote/folder`
+ - SFTP (SSH key): `sftp://username@hostname:22/path?key=~/.ssh/id_ed25519`
+ - SFTP (SSH key with passphrase): `sftp://username@hostname:22/path?key=~/.ssh/id_ed25519&key_pass=`
+ - SFTP (SSH agent): `sftp://username@hostname:22/path?agent=true`
+
+The URL path (e.g., `/path/to/remote/folder`) is used as the remote destination prefix. All synced files and directories are placed relative to this path. **The remote directory must already exist** before starting the sync.
+
+SFTP URL query parameters:
+
+| Parameter | Description | Example |
+| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
+| `key` | Path to SSH private key file (supports `~` expansion). If omitted, auto-detects `~/.ssh/id_ed25519`, `~/.ssh/id_ecdsa`, `~/.ssh/id_rsa` | `key=~/.ssh/custom_key` |
+| `key_pass` | Passphrase for encrypted private keys | `key_pass=mysecret` |
+| `agent` | Use SSH agent for authentication when set to `true` | `agent=true` |
+
+Authentication methods are tried in order: SSH agent → private key → password.
+
+> **Security note:** Avoid putting real passwords/passphrases directly in CLI arguments when possible,
+> as they can be exposed via shell history and process listings.
+
- `--exclude`: (Optional) Specifies paths or glob patterns to exclude from synchronization. Supports `*`, `**`, and `?`. You can specify multiple `--exclude` options.
### Sync Command Arguments
@@ -196,7 +239,7 @@ The application uses structured error handling with specific exit codes:
## Roadmap
- [x] Support for patterns in the `--exclude` option.
-- [ ] Support of Secure FTP (SFTP) protocol.
+- [x] Support of Secure FTP (SFTP) protocol.
- [ ] Improved error handling and error messages.
- [ ] Integration with Git for automatic syncing on commit or branch changes.
- [ ] Integration with Git for linking branch to remote server.
diff --git a/go.mod b/go.mod
index a6b3aa9..f716623 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/capcom6/sftp-sync
-go 1.24.3
+go 1.25.0
require (
github.com/bmatcuk/doublestar/v4 v4.10.0
@@ -8,13 +8,16 @@ require (
github.com/go-core-fx/cli-logger v0.0.0-20260319073231-90ee4649c242
github.com/jlaffaye/ftp v0.2.0
github.com/joho/godotenv v1.5.1
+ github.com/pkg/sftp v1.13.10
github.com/samber/lo v1.52.0
github.com/urfave/cli/v3 v3.7.0
+ golang.org/x/crypto v0.51.0
)
require (
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
- golang.org/x/sys v0.12.0 // indirect
- golang.org/x/text v0.22.0 // indirect
+ github.com/kr/fs v0.1.0 // indirect
+ golang.org/x/sys v0.44.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
)
diff --git a/go.sum b/go.sum
index 66f5b2e..ddcc9bb 100644
--- a/go.sum
+++ b/go.sum
@@ -15,6 +15,10 @@ github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg=
github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
+github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
@@ -23,10 +27,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U=
github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
-golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/cli/commands/sync/sync.go b/internal/cli/commands/sync/sync.go
index 187b69d..5865e2c 100644
--- a/internal/cli/commands/sync/sync.go
+++ b/internal/cli/commands/sync/sync.go
@@ -16,7 +16,7 @@ import (
func Command() *cli.Command {
return &cli.Command{
Name: "sync",
- Usage: "watch a local folder for changes and sync them to a remote FTP server.",
+ Usage: "watch a local folder for changes and sync them to a remote FTP or SFTP server.",
Arguments: []cli.Argument{
&cli.StringArg{
Name: "source",
@@ -29,7 +29,7 @@ func Command() *cli.Command {
Flags: []cli.Flag{
&cli.StringFlag{
Name: "dest",
- Usage: "destination FTP server URL",
+ Usage: "destination FTP/SFTP server URL (e.g., ftp://user:pass@host/path or sftp://user:pass@host:22/path)",
Required: true,
},
&cli.StringSliceFlag{
diff --git a/internal/client/client.go b/internal/client/client.go
index a981ce4..693826e 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -5,6 +5,9 @@ import (
"fmt"
"net/url"
+ "github.com/capcom6/sftp-sync/internal/client/ftp"
+ "github.com/capcom6/sftp-sync/internal/client/sftp"
+ "github.com/capcom6/sftp-sync/internal/client/types"
logger "github.com/go-core-fx/cli-logger"
)
@@ -24,9 +27,12 @@ func New(address string, log logger.Logger) (Client, error) {
return nil, fmt.Errorf("failed to parse URL: %w", err)
}
- if u.Scheme == "ftp" {
- return NewFtpClient(address, log.WithContext("client", "")), nil
+ switch u.Scheme {
+ case "ftp":
+ return ftp.NewClient(address, log.WithContext("ftp", "")), nil
+ case "sftp":
+ return sftp.NewClient(address, log.WithContext("sftp", "")), nil
}
- return nil, fmt.Errorf("%w: %s", ErrUnsupportedScheme, u.Scheme)
+ return nil, fmt.Errorf("%w: %s (supported: ftp, sftp)", types.ErrUnsupportedScheme, u.Scheme)
}
diff --git a/internal/client/ftp.go b/internal/client/ftp/ftp.go
similarity index 68%
rename from internal/client/ftp.go
rename to internal/client/ftp/ftp.go
index f6cea75..98611ae 100644
--- a/internal/client/ftp.go
+++ b/internal/client/ftp/ftp.go
@@ -1,8 +1,9 @@
-package client
+package ftp
import (
"context"
"fmt"
+ "net"
"net/textproto"
"net/url"
"os"
@@ -10,12 +11,13 @@ import (
"strings"
"sync"
+ "github.com/capcom6/sftp-sync/internal/client/types"
logger "github.com/go-core-fx/cli-logger"
"github.com/jlaffaye/ftp"
"github.com/samber/lo"
)
-type FtpClient struct {
+type Client struct {
url string
logger logger.Logger
@@ -24,8 +26,8 @@ type FtpClient struct {
lock sync.Mutex
}
-func NewFtpClient(url string, logger logger.Logger) *FtpClient {
- return &FtpClient{
+func NewClient(url string, logger logger.Logger) *Client {
+ return &Client{
url: url,
logger: logger,
@@ -35,7 +37,7 @@ func NewFtpClient(url string, logger logger.Logger) *FtpClient {
}
}
-func (c *FtpClient) init(ctx context.Context) error {
+func (c *Client) init(ctx context.Context) error {
c.lock.Lock()
defer c.lock.Unlock()
@@ -59,33 +61,50 @@ func (c *FtpClient) init(ctx context.Context) error {
}
if u.Scheme != "ftp" {
- return fmt.Errorf("%w: %s", ErrUnsupportedScheme, u.Scheme)
+ return fmt.Errorf("%w: %s", types.ErrUnsupportedScheme, u.Scheme)
}
- c.client, err = ftp.Dial(u.Host, ftp.DialWithContext(ctx))
+ host := u.Host
+ if u.Port() == "" {
+ host = net.JoinHostPort(u.Hostname(), "21")
+ }
+
+ conn, err := ftp.Dial(host, ftp.DialWithContext(ctx))
if err != nil {
return fmt.Errorf("can't connect to %s: %w", u.Host, err)
}
- password, ok := u.User.Password()
- if !ok {
- password = ""
+ user := u.User
+ if user == nil || user.Username() == "" {
+ _ = conn.Quit()
+ return fmt.Errorf("%w: missing FTP username in URL", types.ErrInvalidParams)
}
-
- if loginErr := c.client.Login(u.User.Username(), password); loginErr != nil {
- return fmt.Errorf("can't login as %s: %w", u.User.Username(), loginErr)
+ password, _ := user.Password()
+ if loginErr := conn.Login(user.Username(), password); loginErr != nil {
+ _ = conn.Quit()
+ return fmt.Errorf("can't login as %s: %w", user.Username(), loginErr)
}
- if chErr := c.client.ChangeDir(u.Path); chErr != nil {
- return fmt.Errorf("can't change directory to %s: %w", u.Path, chErr)
+ if u.Path != "" && u.Path != "/" {
+ if chErr := conn.ChangeDir(u.Path); chErr != nil {
+ _ = conn.Quit()
+ return fmt.Errorf(
+ "%w: remote path %s does not exist or is not accessible: %w",
+ types.ErrInvalidPath,
+ u.Path,
+ chErr,
+ )
+ }
}
+ c.client = conn
+
return nil
}
-func (c *FtpClient) ping(_ context.Context) error {
+func (c *Client) ping(_ context.Context) error {
if c.client == nil {
- return ErrClientIsNil
+ return types.ErrClientIsNil
}
if err := c.client.NoOp(); err != nil {
@@ -95,7 +114,7 @@ func (c *FtpClient) ping(_ context.Context) error {
return nil
}
-func (c *FtpClient) MakeDir(ctx context.Context, remotePath string) error {
+func (c *Client) MakeDir(ctx context.Context, remotePath string) error {
if err := c.init(ctx); err != nil {
return err
}
@@ -117,7 +136,7 @@ func (c *FtpClient) MakeDir(ctx context.Context, remotePath string) error {
return nil
}
-func (c *FtpClient) RemoveDir(ctx context.Context, remotePath string) error {
+func (c *Client) RemoveDir(ctx context.Context, remotePath string) error {
if err := c.init(ctx); err != nil {
return err
}
@@ -133,7 +152,7 @@ func (c *FtpClient) RemoveDir(ctx context.Context, remotePath string) error {
return nil
}
-func (c *FtpClient) UploadFile(ctx context.Context, remotePath string, localPath string) error {
+func (c *Client) UploadFile(ctx context.Context, remotePath string, localPath string) error {
if err := c.init(ctx); err != nil {
return err
}
@@ -156,7 +175,7 @@ func (c *FtpClient) UploadFile(ctx context.Context, remotePath string, localPath
return nil
}
-func (c *FtpClient) RemoveFile(ctx context.Context, remotePath string) error {
+func (c *Client) RemoveFile(ctx context.Context, remotePath string) error {
if err := c.init(ctx); err != nil {
return err
}
@@ -169,7 +188,7 @@ func (c *FtpClient) RemoveFile(ctx context.Context, remotePath string) error {
return nil
}
-func (c *FtpClient) Remove(ctx context.Context, remotePath string) error {
+func (c *Client) Remove(ctx context.Context, remotePath string) error {
if err := c.init(ctx); err != nil {
return err
}
diff --git a/internal/client/ftp/ftp_test.go b/internal/client/ftp/ftp_test.go
new file mode 100644
index 0000000..8b0fadc
--- /dev/null
+++ b/internal/client/ftp/ftp_test.go
@@ -0,0 +1,41 @@
+package ftp_test
+
+import (
+ "net/url"
+ "testing"
+)
+
+func TestURLPathExtraction(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ rawURL string
+ wantPath string
+ }{
+ {
+ name: "nested path",
+ rawURL: "ftp://user:pass@host:21/uploads/project/files",
+ wantPath: "/uploads/project/files",
+ },
+ {name: "single directory", rawURL: "ftp://user:pass@host:21/data", wantPath: "/data"},
+ {name: "root path", rawURL: "ftp://user:pass@host:21/", wantPath: "/"},
+ {name: "no trailing slash", rawURL: "ftp://user@host:21/path", wantPath: "/path"},
+ {name: "with port", rawURL: "ftp://user:pass@host:2121/path/to/dir", wantPath: "/path/to/dir"},
+ {name: "empty path", rawURL: "ftp://user:pass@host:21", wantPath: ""},
+ {name: "path with special chars", rawURL: "ftp://user:pass@host:21/my%20folder", wantPath: "/my folder"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ u, err := url.Parse(tt.rawURL)
+ if err != nil {
+ t.Fatalf("url.Parse(%q) error = %v", tt.rawURL, err)
+ }
+ if u.Path != tt.wantPath {
+ t.Errorf("path = %q, want %q", u.Path, tt.wantPath)
+ }
+ })
+ }
+}
diff --git a/internal/client/sftp/auth.go b/internal/client/sftp/auth.go
new file mode 100644
index 0000000..cb4fcd0
--- /dev/null
+++ b/internal/client/sftp/auth.go
@@ -0,0 +1,177 @@
+package sftp
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/agent"
+)
+
+const agentDialTimeout = 5 * time.Second
+
+var (
+ ErrEncryptedKeyNoPassphrase = errors.New("SSH key is encrypted but no key_pass provided")
+)
+
+type AuthConfig struct {
+ password string
+ privateKey string
+ keyPass string
+ useAgent bool
+}
+
+func ParseAuthFromURL(u *url.URL) AuthConfig {
+ cfg := AuthConfig{
+ password: "",
+ privateKey: "",
+ keyPass: "",
+ useAgent: false,
+ }
+
+ if u.User != nil {
+ if pw, ok := u.User.Password(); ok {
+ cfg.password = pw
+ }
+ }
+
+ cfg.privateKey = u.Query().Get("key")
+ cfg.keyPass = u.Query().Get("key_pass")
+ cfg.useAgent = u.Query().Get("agent") == "true"
+
+ return cfg
+}
+
+func (a AuthConfig) BuildAuthMethods() ([]ssh.AuthMethod, error) {
+ var methods []ssh.AuthMethod
+
+ if a.useAgent && os.Getenv("SSH_AUTH_SOCK") != "" {
+ agentSigners, agentErr := a.buildAgentSigners()
+ if agentErr != nil {
+ return nil, agentErr
+ }
+ if len(agentSigners) > 0 {
+ methods = append(methods, ssh.PublicKeys(agentSigners...))
+ }
+ }
+
+ keyPath := a.privateKey
+ if keyPath == "" && len(methods) == 0 && a.password == "" {
+ keyPath = findDefaultKeyPath()
+ }
+
+ if keyPath != "" {
+ signer, keyErr := parseKeyAtPath(keyPath, a.keyPass)
+ if keyErr != nil {
+ return nil, keyErr
+ }
+ methods = append(methods, ssh.PublicKeys(signer))
+ }
+
+ if a.password != "" {
+ methods = append(methods, ssh.Password(a.password))
+ }
+
+ return methods, nil
+}
+
+func (a AuthConfig) buildAgentSigners() ([]ssh.Signer, error) {
+ sock := os.Getenv("SSH_AUTH_SOCK")
+
+ dialer := &net.Dialer{Timeout: agentDialTimeout} //nolint:exhaustruct // only Timeout is relevant for agent socket
+ conn, dialErr := dialer.DialContext(context.Background(), "unix", sock)
+ if dialErr != nil {
+ return nil, fmt.Errorf("failed to connect to SSH agent: %w", dialErr)
+ }
+ defer conn.Close()
+
+ agentClient := agent.NewClient(conn)
+ signers, signErr := agentClient.Signers()
+ if signErr != nil {
+ return nil, fmt.Errorf("failed to get signers from SSH agent: %w", signErr)
+ }
+
+ return signers, nil
+}
+
+func parseKeyAtPath(path, keyPass string) (ssh.Signer, error) {
+ path = expandTilde(path)
+
+ keyBytes, readErr := os.ReadFile(path)
+ if readErr != nil {
+ return nil, fmt.Errorf("failed to read SSH key %q: %w", path, readErr)
+ }
+
+ if keyPass != "" {
+ signer, parseErr := ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(keyPass))
+ if parseErr != nil {
+ return nil, fmt.Errorf("failed to parse encrypted SSH key %q: %w", path, parseErr)
+ }
+ return signer, nil
+ }
+
+ signer, parseErr := ssh.ParsePrivateKey(keyBytes)
+ if parseErr != nil && isEncryptedKeyError(parseErr) {
+ return nil, fmt.Errorf("SSH key %q: %w", path, ErrEncryptedKeyNoPassphrase)
+ }
+ if parseErr != nil {
+ return nil, fmt.Errorf("failed to parse SSH key %q: %w", path, parseErr)
+ }
+
+ return signer, nil
+}
+
+func isEncryptedKeyError(err error) bool {
+ msg := err.Error()
+ return strings.Contains(msg, "password protected") ||
+ strings.Contains(msg, "encrypted")
+}
+
+func getDefaultKeyNames() []string {
+ return []string{"id_ed25519", "id_ecdsa", "id_rsa"}
+}
+
+func findDefaultKeyPath() string {
+ home, homeErr := os.UserHomeDir()
+ if homeErr != nil {
+ return ""
+ }
+
+ sshDir := filepath.Join(home, ".ssh")
+
+ for _, name := range getDefaultKeyNames() {
+ candidate := filepath.Join(sshDir, name)
+ if _, statErr := os.Stat(candidate); statErr == nil {
+ return candidate
+ }
+ }
+
+ return ""
+}
+
+func expandTilde(p string) string {
+ if p == "~" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return p
+ }
+ return home
+ }
+
+ if strings.HasPrefix(p, "~/") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return p
+ }
+ return filepath.Join(home, p[2:])
+ }
+
+ return p
+}
diff --git a/internal/client/sftp/config.go b/internal/client/sftp/config.go
new file mode 100644
index 0000000..ebd2734
--- /dev/null
+++ b/internal/client/sftp/config.go
@@ -0,0 +1,49 @@
+package sftp
+
+import (
+ "fmt"
+ "net/url"
+ "time"
+
+ "github.com/capcom6/sftp-sync/internal/client/types"
+ "github.com/samber/lo"
+)
+
+const defaultConnectTimeout = 30 * time.Second
+
+type Config struct {
+ Host string
+ Port string
+ Username string
+ Auth AuthConfig
+ ConnectTimeout time.Duration
+}
+
+func ParseConfigFromURL(u *url.URL) (Config, error) {
+ hostname := u.Hostname()
+ if hostname == "" {
+ return Config{}, fmt.Errorf("%w: sftp URL must include host", types.ErrInvalidParams)
+ }
+
+ user := u.User
+ if user == nil || user.Username() == "" {
+ return Config{}, fmt.Errorf("%w: sftp URL must include username", types.ErrInvalidParams)
+ }
+
+ timeout := defaultConnectTimeout
+ if timeoutStr := u.Query().Get("timeout"); timeoutStr != "" {
+ var err error
+ timeout, err = time.ParseDuration(timeoutStr)
+ if err != nil {
+ return Config{}, fmt.Errorf("failed to parse timeout: %w", err)
+ }
+ }
+
+ return Config{
+ Host: hostname,
+ Port: lo.CoalesceOrEmpty(u.Port(), "22"),
+ Username: user.Username(),
+ Auth: ParseAuthFromURL(u),
+ ConnectTimeout: timeout,
+ }, nil
+}
diff --git a/internal/client/sftp/sftp.go b/internal/client/sftp/sftp.go
new file mode 100644
index 0000000..5f6ccb8
--- /dev/null
+++ b/internal/client/sftp/sftp.go
@@ -0,0 +1,376 @@
+package sftp
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/capcom6/sftp-sync/internal/client/types"
+ logger "github.com/go-core-fx/cli-logger"
+ "github.com/pkg/sftp"
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/knownhosts"
+)
+
+type Client struct {
+ url string
+ basePath string
+
+ logger logger.Logger
+
+ conn *ssh.Client
+ sftp *sftp.Client
+ lock sync.Mutex
+}
+
+func NewClient(url string, logger logger.Logger) *Client {
+ return &Client{
+ url: url,
+ basePath: "",
+
+ logger: logger,
+ conn: nil,
+ sftp: nil,
+ lock: sync.Mutex{},
+ }
+}
+
+func (c *Client) init(ctx context.Context) error {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ if c.sftp != nil {
+ pingErr := c.ping(ctx)
+ if pingErr == nil {
+ return nil
+ }
+
+ c.logger.Warn(ctx, "Reconnecting because of error", logger.Fields{
+ "error": pingErr,
+ })
+
+ _ = c.sftp.Close()
+ _ = c.conn.Close()
+ c.sftp = nil
+ c.conn = nil
+ }
+
+ u, err := url.Parse(c.url)
+ if err != nil {
+ return fmt.Errorf("can't parse URL: %w", err)
+ }
+
+ if u.Scheme != "sftp" {
+ return fmt.Errorf("%w: %s", types.ErrUnsupportedScheme, u.Scheme)
+ }
+
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return fmt.Errorf("can't get home directory: %w", err)
+ }
+
+ hostKeyCallback, err := knownhosts.New(filepath.Join(homeDir, ".ssh", "known_hosts"))
+ if err != nil {
+ return fmt.Errorf("can't load known_hosts: %w", err)
+ }
+
+ config, err := ParseConfigFromURL(u)
+ if err != nil {
+ return err
+ }
+
+ authMethods, err := config.Auth.BuildAuthMethods()
+ if err != nil {
+ return fmt.Errorf("can't build auth methods: %w", err)
+ }
+
+ client, conn, err := c.connect(ctx, config, authMethods, hostKeyCallback)
+ if err != nil {
+ return err
+ }
+
+ if u.Path != "" && u.Path != "/" {
+ info, statErr := client.Stat(u.Path)
+ if statErr != nil {
+ _ = client.Close()
+ _ = conn.Close()
+ return fmt.Errorf("%w: remote path %s does not exist: %w", types.ErrInvalidPath, u.Path, statErr)
+ }
+ if !info.IsDir() {
+ _ = client.Close()
+ _ = conn.Close()
+ return fmt.Errorf("%w: remote path %s is not a directory", types.ErrInvalidPath, u.Path)
+ }
+ c.basePath = u.Path
+ } else {
+ c.basePath = ""
+ }
+
+ c.conn = conn
+ c.sftp = client
+
+ return nil
+}
+
+func (c *Client) connect(
+ ctx context.Context,
+ config Config,
+ authMethods []ssh.AuthMethod,
+ hostKeyCallback ssh.HostKeyCallback,
+) (*sftp.Client, *ssh.Client, error) {
+ sshConfig := &ssh.ClientConfig{
+ User: config.Username,
+ Auth: authMethods,
+ HostKeyCallback: hostKeyCallback,
+ }
+
+ addr := net.JoinHostPort(config.Host, config.Port)
+ dialer := &net.Dialer{Timeout: config.ConnectTimeout} //nolint:exhaustruct // only Timeout is relevant
+ netConn, err := dialer.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, nil, fmt.Errorf("can't connect to %s: %w", addr, err)
+ }
+
+ sshConn, chans, reqs, err := ssh.NewClientConn(netConn, addr, sshConfig)
+ if err != nil {
+ _ = netConn.Close()
+ return nil, nil, fmt.Errorf("can't connect to %s: %w", addr, err)
+ }
+
+ conn := ssh.NewClient(sshConn, chans, reqs)
+
+ client, err := sftp.NewClient(conn)
+ if err != nil {
+ _ = conn.Close()
+ return nil, nil, fmt.Errorf("can't create SFTP client: %w", err)
+ }
+
+ return client, conn, nil
+}
+
+func (c *Client) ping(_ context.Context) error {
+ if _, err := c.sftp.Getwd(); err != nil {
+ return fmt.Errorf("failed to ping: %w", err)
+ }
+
+ return nil
+}
+
+func (c *Client) resolvePath(remotePath string) string {
+ if c.basePath == "" {
+ return remotePath
+ }
+ if remotePath == "" {
+ return c.basePath
+ }
+ return path.Join(c.basePath, remotePath)
+}
+
+func (c *Client) MakeDir(ctx context.Context, remotePath string) error {
+ if err := c.init(ctx); err != nil {
+ return err
+ }
+
+ if remotePath == "" {
+ return nil
+ }
+
+ fullPath := c.resolvePath(remotePath)
+ dirs := splitPath(fullPath)
+ dirs = append(dirs, fullPath)
+
+ for _, dir := range dirs {
+ c.logger.Debug(ctx, "Creating directory", logger.Fields{
+ "path": dir,
+ })
+
+ if _, err := c.sftp.Stat(dir); err == nil {
+ continue
+ }
+
+ if err := c.sftp.Mkdir(dir); err != nil {
+ if isIgnorableError(err) {
+ continue
+ }
+ return fmt.Errorf("can't make directory %s: %w", dir, err)
+ }
+ }
+
+ return nil
+}
+
+func (c *Client) RemoveDir(ctx context.Context, remotePath string) error {
+ if err := c.init(ctx); err != nil {
+ return err
+ }
+
+ if err := c.removeDirRecur(ctx, c.resolvePath(remotePath)); err != nil {
+ if isIgnorableError(err) {
+ return nil
+ }
+ return fmt.Errorf("can't remove directory %s: %w", remotePath, err)
+ }
+
+ return nil
+}
+
+func (c *Client) removeDirRecur(ctx context.Context, p string) error {
+ entries, err := c.sftp.ReadDirContext(ctx, p)
+ if err != nil {
+ if isIgnorableError(err) {
+ return nil
+ }
+ return fmt.Errorf("can't read directory %s: %w", p, err)
+ }
+
+ for _, entry := range entries {
+ entryPath := path.Join(p, entry.Name())
+
+ if entry.IsDir() {
+ if rmErr := c.removeDirRecur(ctx, entryPath); rmErr != nil {
+ return fmt.Errorf("can't remove directory %s: %w", entryPath, rmErr)
+ }
+
+ continue
+ }
+
+ if rmErr := c.sftp.Remove(entryPath); rmErr != nil {
+ if isIgnorableError(rmErr) {
+ continue
+ }
+ return fmt.Errorf("can't remove file %s: %w", entryPath, rmErr)
+ }
+ }
+
+ if rmErr := c.sftp.RemoveDirectory(p); rmErr != nil {
+ if isIgnorableError(rmErr) {
+ return nil
+ }
+ return fmt.Errorf("can't remove directory %s: %w", p, rmErr)
+ }
+
+ return nil
+}
+
+func (c *Client) UploadFile(ctx context.Context, remotePath string, localPath string) error {
+ if err := c.init(ctx); err != nil {
+ return err
+ }
+
+ fullPath := c.resolvePath(remotePath)
+ dir, _ := path.Split(remotePath)
+ if err := c.MakeDir(ctx, dir); err != nil {
+ return err
+ }
+
+ localFile, err := os.Open(localPath)
+ if err != nil {
+ return fmt.Errorf("can't open local file %s: %w", localPath, err)
+ }
+ defer localFile.Close()
+
+ remoteFile, err := c.sftp.Create(fullPath)
+ if err != nil {
+ return fmt.Errorf("can't create remote file %s: %w", fullPath, err)
+ }
+ defer remoteFile.Close()
+
+ written, err := remoteFile.ReadFrom(localFile)
+ if err != nil {
+ return fmt.Errorf("can't upload file to %s: %w", fullPath, err)
+ }
+
+ c.logger.Debug(ctx, "File uploaded", logger.Fields{
+ "path": fullPath,
+ "bytes": written,
+ "local": localPath,
+ })
+
+ return nil
+}
+
+func (c *Client) RemoveFile(ctx context.Context, remotePath string) error {
+ if err := c.init(ctx); err != nil {
+ return err
+ }
+
+ if err := c.sftp.Remove(c.resolvePath(remotePath)); err != nil {
+ if isIgnorableError(err) {
+ return nil
+ }
+ return fmt.Errorf("can't remove file %s: %w", remotePath, err)
+ }
+
+ return nil
+}
+
+func (c *Client) Remove(ctx context.Context, remotePath string) error {
+ if err := c.init(ctx); err != nil {
+ return err
+ }
+
+ info, err := c.sftp.Stat(c.resolvePath(remotePath))
+ if err != nil {
+ if isIgnorableError(err) {
+ return nil
+ }
+ return fmt.Errorf("can't stat %s: %w", remotePath, err)
+ }
+
+ if info.IsDir() {
+ return c.RemoveDir(ctx, remotePath)
+ }
+
+ return c.RemoveFile(ctx, remotePath)
+}
+
+func isIgnorableError(err error) bool {
+ if err == nil {
+ return true
+ }
+
+ errStr := err.Error()
+ notFoundErrors := []string{
+ "does not exist",
+ "not found",
+ "No such file",
+ }
+
+ for _, pattern := range notFoundErrors {
+ if strings.Contains(errStr, pattern) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func splitPath(dir string) []string {
+ if dir == "" || dir == "." {
+ return nil
+ }
+
+ entries := make([]string, 0, strings.Count(dir, "/"))
+
+ dir = path.Clean(dir)
+
+ for {
+ dir = path.Dir(dir)
+ if dir == "." || dir == "/" {
+ break
+ }
+ entries = append(entries, dir)
+ }
+
+ for i := range len(entries) / 2 {
+ entries[i], entries[len(entries)-i-1] = entries[len(entries)-i-1], entries[i]
+ }
+
+ return entries
+}
diff --git a/internal/client/errors.go b/internal/client/types/errors.go
similarity index 53%
rename from internal/client/errors.go
rename to internal/client/types/errors.go
index 559b8bb..d3ea594 100644
--- a/internal/client/errors.go
+++ b/internal/client/types/errors.go
@@ -1,8 +1,10 @@
-package client
+package types
import "errors"
var (
ErrUnsupportedScheme = errors.New("unsupported scheme")
ErrClientIsNil = errors.New("client is nil")
+ ErrInvalidParams = errors.New("invalid params")
+ ErrInvalidPath = errors.New("invalid path")
)
diff --git a/main.go b/main.go
index 79744b8..8406848 100644
--- a/main.go
+++ b/main.go
@@ -54,7 +54,7 @@ func main() {
app := &cli.Command{
Name: "sftp-sync",
- Usage: "a command-line utility for syncing a local folder with a remote FTP server on every change of files or directories.",
+ Usage: "a command-line utility for syncing a local folder with a remote FTP or SFTP server on every change of files or directories.",
Version: appVersion,
ArgsUsage: "[source]",
Arguments: []cli.Argument{
@@ -75,7 +75,7 @@ func main() {
&cli.StringFlag{
Name: "dest",
- Usage: "destination FTP server URL",
+ Usage: "destination FTP/SFTP server URL (e.g., ftp://user:pass@host/path or sftp://user:pass@host:22/path)",
Required: true,
},
&cli.StringSliceFlag{