Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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. <br />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` |
Expand Down Expand Up @@ -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. <br />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. <br />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. <br />See [Alerting](#alerting). | `[]` |
| `endpoints[].maintenance-windows` | List of all maintenance windows for a given endpoint. <br />See [Maintenance](#maintenance). | `[]` |
| `endpoints[].client` | [Client configuration](#client-configuration). | `{}` |
Expand Down Expand Up @@ -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:
Expand Down
93 changes: 93 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions config/endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -53,6 +55,7 @@ const (
TypeGRPC Type = "GRPC"
TypeWS Type = "WEBSOCKET"
TypeSSH Type = "SSH"
TypeSFTP Type = "SFTP"
TypeUNKNOWN Type = "UNKNOWN"
)

Expand Down Expand Up @@ -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"`

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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://")
Expand Down
37 changes: 37 additions & 0 deletions config/endpoint/sftp/sftp.go
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 38 additions & 0 deletions config/endpoint/sftp/sftp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading