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{