diff --git a/README.md b/README.md
index 2578500fb..5633e7c60 100644
--- a/README.md
+++ b/README.md
@@ -124,6 +124,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Monitoring an endpoint using ICMP](#monitoring-an-endpoint-using-icmp)
- [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries)
- [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh)
+ - [Monitoring an endpoint using SFTP](#monitoring-an-endpoint-using-sftp)
- [Monitoring an endpoint using STARTTLS](#monitoring-an-endpoint-using-starttls)
- [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls)
- [Monitoring domain expiration](#monitoring-domain-expiration)
@@ -254,7 +255,7 @@ If you want to test it locally, see [Docker](#docker).
| `concurrency` | Maximum number of endpoints/suites to monitor concurrently. Set to `0` for unlimited. See [Concurrency](#concurrency). | `3` |
| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). **Deprecated**: Use `concurrency: 0` instead. | `false` |
| `skip-invalid-config-update` | Whether to ignore invalid configuration update.
See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` |
-| `web` | Web configuration. | `{}` |
+| `web` | Web configuration. | `{}` |/monitoring-an-endpoint-using-ssh
| `web.address` | Address to listen on. | `0.0.0.0` |
| `web.port` | Port to listen on. | `8080` |
| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` |
@@ -305,6 +306,10 @@ You can then configure alerts to be triggered when an endpoint is unhealthy once
| `endpoints[].ssh` | Configuration for an endpoint of type SSH.
See [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh). | `""` |
| `endpoints[].ssh.username` | SSH username (e.g. example). | Required `""` |
| `endpoints[].ssh.password` | SSH password (e.g. password). | Required `""` |
+| `endpoints[].sftp` | Configuration for an endpoint of type SFTP.
See [Monitoring an endpoint using SFTP](#monitoring-an-endpoint-using-sftp). | `""` |
+| `endpoints[].sftp.username` | SFTP username (e.g. example). | Required `""` |
+| `endpoints[].sftp.password` | SFTP password (e.g. password). | Required `""` |
+| `endpoints[].sftp.path` | SFTP path (e.g. /). | `false` |
| `endpoints[].alerts` | List of all alerts for a given endpoint.
See [Alerting](#alerting). | `[]` |
| `endpoints[].maintenance-windows` | List of all maintenance windows for a given endpoint.
See [Maintenance](#maintenance). | `[]` |
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
@@ -3105,6 +3110,67 @@ The following placeholders are supported for endpoints of type SSH:
- `[RESPONSE_TIME]` resolves to the time it took to establish the connection and execute the command
+### Monitoring an endpoint using SFTP
+You can monitor endpoints using SFTP by prefixing `endpoints[].url` with `sftp://`:
+```yaml
+endpoints:
+ # Password-based SFTP example
+ - name: sftp-example-password
+ url: "sftp://example.com:22" # port is optional. Default is 22.
+ sftp:
+ username: "username"
+ password: "password"
+ path: "/test" # Optional. Defaults to "." if not specified.
+ interval: 1m
+ conditions:
+ - "[CONNECTED] == true"
+ - "[STATUS] == 0"
+ - "[BODY].files[0] == file1"
+
+ # Key-based SFTP example
+ - name: sftp-example-key
+ url: "sftp://example.com:22" # port is optional. Default is 22.
+ sftp:
+ username: "username"
+ private-key: |
+ -----BEGIN RSA PRIVATE KEY-----
+ TESTRSAKEY...
+ -----END RSA PRIVATE KEY-----
+ path: "/test" # Optional. Defaults to "." if not specified.
+ interval: 1m
+ conditions:
+ - "[CONNECTED] == true"
+ - "[STATUS] == 0"
+ - "len([BODY].files) > 0"
+```
+
+you can also use no authentication to monitor the endpoint by not specifying the username,
+password and private key fields.
+
+```yaml
+endpoints:
+ - name: sftp-example
+ url: "sftp://example.com:22" # port is optional. Default is 22.
+ sftp:
+ username: ""
+ password: ""
+ private-key: ""
+ path: "/test" # Optional. Defaults to "." if not specified.
+ interval: 1m
+ conditions:
+ - "[CONNECTED] == true"
+ - "[STATUS] == 0"
+ - "len([BODY].files) > 0"
+```
+
+The following placeholders are supported for endpoints of type SFTP:
+- `[CONNECTED]` resolves to `true` if the SFTP connection was successful, `false` otherwise
+- `[STATUS]` resolves to `0` for success (since directory listing doesn't have an exit code like commands)
+- `[BODY]` resolves to a JSON object like {"files": ["file1", "file2", "file3"]}
+- `[IP]` resolves to the IP address of the server
+- `[RESPONSE_TIME]` resolves to the time it took to establish the connection and list the directory
+
+
### Monitoring an endpoint using STARTTLS
If you have an email server that you want to ensure there are no problems with, monitoring it through STARTTLS
will serve as a good initial indicator:
diff --git a/client/client.go b/client/client.go
index b7abf1758..f028cacba 100644
--- a/client/client.go
+++ b/client/client.go
@@ -23,6 +23,7 @@ import (
"github.com/TwiN/whois"
"github.com/ishidawataru/sctp"
"github.com/miekg/dns"
+ "github.com/pkg/sftp"
ping "github.com/prometheus-community/pro-bing"
"github.com/registrobr/rdap"
"github.com/registrobr/rdap/protocol"
@@ -349,6 +350,98 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
return true, exitErr.ExitStatus(), output, nil
}
+// CanCreateSFTPConnection checks whether a connection can be established and a command can be executed to an address
+// using the SFTP protocol.
+func CanCreateSFTPConnection(address, username, password, privateKey string, config *Config) (bool, *sftp.Client, error) {
+ var port string
+ if strings.Contains(address, ":") {
+ addressAndPort := strings.Split(address, ":")
+ if len(addressAndPort) != 2 {
+ return false, nil, errors.New("invalid address for sftp, format must be host:port")
+ }
+ address = addressAndPort[0]
+ port = addressAndPort[1]
+ } else {
+ port = "22"
+ }
+
+ // Build auth methods: prefer parsed private key if provided, fall back to password.
+ var authMethods []ssh.AuthMethod
+ if len(privateKey) > 0 {
+ if signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {
+ authMethods = append(authMethods, ssh.PublicKeys(signer))
+ } else {
+ return false, nil, fmt.Errorf("invalid private key: %w", err)
+ }
+ }
+ if len(password) > 0 {
+ authMethods = append(authMethods, ssh.Password(password))
+ }
+
+ sshClient, err := ssh.Dial("tcp", net.JoinHostPort(address, port), &ssh.ClientConfig{
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ User: username,
+ Auth: authMethods,
+ Timeout: config.Timeout,
+ })
+ if err != nil {
+ return false, nil, err
+ }
+
+ sftpClient, err := sftp.NewClient(sshClient)
+ if err != nil {
+ sshClient.Close()
+ return false, nil, err
+ }
+ return true, sftpClient, nil
+}
+
+func CheckSFTPConnection(address string, config *Config) (bool, error) {
+ var port string
+ if strings.Contains(address, ":") {
+ addressAndPort := strings.Split(address, ":")
+ if len(addressAndPort) != 2 {
+ return false, errors.New("invalid address for sftp, format must be host:port")
+ }
+ address = addressAndPort[0]
+ port = addressAndPort[1]
+ } else {
+ port = "22"
+ }
+ dialer := net.Dialer{}
+ connStr := net.JoinHostPort(address, port)
+ conn, err := dialer.Dial("tcp", connStr)
+ if err != nil {
+ return false, err
+ }
+ defer conn.Close()
+ conn.SetReadDeadline(time.Now().Add(time.Second))
+ buf := make([]byte, 256)
+ _, err = io.ReadAtLeast(conn, buf, 1)
+ if err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+// ExecuteSFTPCommand executes a command to an address using the SFTP protocol.
+func ExecuteSFTPCommand(sftpClient *sftp.Client, path string, config *Config) (bool, int, []byte, error) {
+ defer sftpClient.Close()
+ files, err := sftpClient.ReadDir(path)
+ if err != nil {
+ return false, 0, nil, err
+ }
+ var fileNames []string
+ for _, file := range files {
+ fileNames = append(fileNames, file.Name())
+ }
+ output, err := json.Marshal(map[string][]string{"files": fileNames})
+ if err != nil {
+ return false, 0, nil, err
+ }
+ return true, 0, output, nil
+}
+
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
//
// Note that this function takes at least 100ms, even if the address is 127.0.0.1
diff --git a/config/endpoint/endpoint.go b/config/endpoint/endpoint.go
index 2014e5098..5926b385c 100644
--- a/config/endpoint/endpoint.go
+++ b/config/endpoint/endpoint.go
@@ -19,11 +19,13 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
+ sftpconfig "github.com/TwiN/gatus/v5/config/endpoint/sftp"
sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/config/gontext"
"github.com/TwiN/gatus/v5/config/key"
"github.com/TwiN/gatus/v5/config/maintenance"
+ "github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
@@ -53,6 +55,7 @@ const (
TypeGRPC Type = "GRPC"
TypeWS Type = "WEBSOCKET"
TypeSSH Type = "SSH"
+ TypeSFTP Type = "SFTP"
TypeUNKNOWN Type = "UNKNOWN"
)
@@ -123,6 +126,9 @@ type Endpoint struct {
// SSH is the configuration for SSH monitoring
SSHConfig *sshconfig.Config `yaml:"ssh,omitempty"`
+ // SFTP is the configuration for SFTP monitoring
+ SFTPConfig *sftpconfig.Config `yaml:"sftp,omitempty"`
+
// ClientConfig is the configuration of the client used to communicate with the endpoint's target
ClientConfig *client.Config `yaml:"client,omitempty"`
@@ -184,6 +190,8 @@ func (e *Endpoint) Type() Type {
return TypeWS
case strings.HasPrefix(e.URL, "ssh://"):
return TypeSSH
+ case strings.HasPrefix(e.URL, "sftp://"):
+ return TypeSFTP
default:
return TypeUNKNOWN
}
@@ -246,6 +254,9 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
if e.SSHConfig != nil {
return e.SSHConfig.Validate()
}
+ if e.SFTPConfig != nil {
+ return e.SFTPConfig.Validate()
+ }
if e.Type() == TypeUNKNOWN {
return ErrUnknownEndpointType
}
@@ -531,6 +542,46 @@ func (e *Endpoint) call(result *Result) {
result.Body = output
}
result.Duration = time.Since(startTime)
+ } else if endpointType == TypeSFTP {
+ // If there's no username, password or private key specified, attempt to validate just the SFTP connection
+ if e.SFTPConfig == nil || (len(e.SFTPConfig.Username) == 0 && len(e.SFTPConfig.Password) == 0 && len(e.SFTPConfig.PrivateKey) == 0) {
+ result.Connected, err = client.CheckSFTPConnection(strings.TrimPrefix(e.URL, "sftp://"), e.ClientConfig)
+ if err != nil {
+ result.AddError(err.Error())
+ return
+ }
+ result.Success = result.Connected
+ result.Duration = time.Since(startTime)
+ return
+ }
+ fullURL := strings.TrimPrefix(e.URL, "sftp://")
+ var address string
+ if idx := strings.Index(fullURL, "/"); idx != -1 {
+ address = fullURL[:idx]
+ } else {
+ address = fullURL
+ }
+ path := e.SFTPConfig.Path
+ if path == "" {
+ path = "."
+ }
+ var sftpCli *sftp.Client
+ result.Connected, sftpCli, err = client.CanCreateSFTPConnection(address, e.SFTPConfig.Username, e.SFTPConfig.Password, e.SFTPConfig.PrivateKey, e.ClientConfig)
+ if err != nil {
+ result.AddError(err.Error())
+ return
+ }
+ var output []byte
+ result.Success, result.HTTPStatus, output, err = client.ExecuteSFTPCommand(sftpCli, path, e.ClientConfig)
+ if err != nil {
+ result.AddError(err.Error())
+ return
+ }
+ // Only store the output in result.Body if there's a condition that uses the BodyPlaceholder
+ if e.needsToReadBody() {
+ result.Body = output
+ }
+ result.Duration = time.Since(startTime)
} else if endpointType == TypeGRPC {
useTLS := strings.HasPrefix(e.URL, "grpcs://")
address := strings.TrimPrefix(strings.TrimPrefix(e.URL, "grpcs://"), "grpc://")
diff --git a/config/endpoint/sftp/sftp.go b/config/endpoint/sftp/sftp.go
new file mode 100644
index 000000000..1e81ba35a
--- /dev/null
+++ b/config/endpoint/sftp/sftp.go
@@ -0,0 +1,37 @@
+package sftp
+
+import (
+ "errors"
+)
+
+var (
+ // ErrEndpointWithoutSFTPUsername is the error with which Gatus will panic if an endpoint with SFTP monitoring is configured without a user.
+ ErrEndpointWithoutSFTPUsername = errors.New("you must specify a username for each SFTP endpoint")
+
+ // ErrEndpointWithoutSFTPAuth is the error with which Gatus will panic if an endpoint with SFTP monitoring is configured without a password or private key.
+ ErrEndpointWithoutSFTPAuth = errors.New("you must specify a password or private-key for each SFTP endpoint")
+)
+
+type Config struct {
+ Username string `yaml:"username,omitempty"`
+ Password string `yaml:"password,omitempty"`
+ PrivateKey string `yaml:"private-key,omitempty"`
+ Path string `yaml:"path,omitempty"`
+}
+
+// Validate the SFTP configuration
+func (cfg *Config) Validate() error {
+ // If there's no username, password, or private key, this endpoint can still check the SFTP connection, so the endpoint is still valid
+ if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
+ return nil
+ }
+ // If any authentication method is provided (password or private key), a username is required
+ if len(cfg.Username) == 0 {
+ return ErrEndpointWithoutSFTPUsername
+ }
+ // If a username is provided, require at least a password or a private key
+ if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
+ return ErrEndpointWithoutSFTPAuth
+ }
+ return nil
+}
diff --git a/config/endpoint/sftp/sftp_test.go b/config/endpoint/sftp/sftp_test.go
new file mode 100644
index 000000000..ae58a642c
--- /dev/null
+++ b/config/endpoint/sftp/sftp_test.go
@@ -0,0 +1,38 @@
+package sftp
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestSFTP_validatePasswordCfg(t *testing.T) {
+ cfg := &Config{}
+ if err := cfg.Validate(); err != nil {
+ t.Error("didn't expect an error")
+ }
+ cfg.Username = "username"
+ if err := cfg.Validate(); err == nil {
+ t.Error("expected an error")
+ } else if !errors.Is(err, ErrEndpointWithoutSFTPAuth) {
+ t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSFTPAuth, err)
+ }
+ cfg.Password = "password"
+ if err := cfg.Validate(); err != nil {
+ t.Errorf("expected no error, got '%v'", err)
+ }
+}
+
+func TestSFTP_validatePrivateKeyCfg(t *testing.T) {
+ t.Run("fail when username missing but private key provided", func(t *testing.T) {
+ cfg := &Config{PrivateKey: "-----BEGIN"}
+ if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSFTPUsername) {
+ t.Fatalf("expected ErrEndpointWithoutSFTPUsername, got %v", err)
+ }
+ })
+ t.Run("success when username with private key", func(t *testing.T) {
+ cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"}
+ if err := cfg.Validate(); err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ })
+}
diff --git a/go.mod b/go.mod
index 11767c037..b8b60cacb 100644
--- a/go.mod
+++ b/go.mod
@@ -23,6 +23,7 @@ require (
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.68
+ github.com/pkg/sftp v1.13.10
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.23.2
github.com/registrobr/rdap v1.1.8
@@ -72,6 +73,7 @@ require (
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
+ github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
diff --git a/go.sum b/go.sum
index af08c1d38..9c4b712cb 100644
--- a/go.sum
+++ b/go.sum
@@ -107,6 +107,8 @@ github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -128,6 +130,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+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/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=