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=