diff --git a/README.md b/README.md
index 89035b4c..8022d9b6 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Monitoring a WebSocket endpoint](#monitoring-a-websocket-endpoint)
- [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 STARTTLS](#monitoring-an-endpoint-using-starttls)
- [Monitoring an endpoint using TLS](#monitoring-an-endpoint-using-tls)
- [Monitoring domain expiration](#monitoring-domain-expiration)
@@ -214,6 +215,9 @@ If you want to test it locally, see [Docker](#docker).
| `endpoints[].dns` | Configuration for an endpoint of type DNS.
See [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries). | `""` |
| `endpoints[].dns.query-type` | Query type (e.g. MX) | `""` |
| `endpoints[].dns.query-name` | Query name (e.g. example.com) | `""` |
+| `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[].alerts[].type` | Type of alert.
See [Alerting](#alerting) for all valid types. | Required `""` |
| `endpoints[].alerts[].enabled` | Whether to enable the alert. | `true` |
| `endpoints[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` |
@@ -1585,6 +1589,28 @@ There are two placeholders that can be used in the conditions for endpoints of t
- The placeholder `[DNS_RCODE]` resolves to the name associated to the response code returned by the query, such as
`NOERROR`, `FORMERR`, `SERVFAIL`, `NXDOMAIN`, etc.
+### Monitoring an endpoint using SSH
+You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh:\\`:
+```yaml
+endpoints:
+ - name: ssh-example
+ url: "ssh://example.com:22" # port is optional. Default is 22.
+ ssh:
+ username: "username"
+ password: "password"
+ body: |
+ {
+ "command": "uptime"
+ }
+ interval: 1m
+ conditions:
+ - "[CONNECTED] == true"
+ - "[STATUS] == 0"
+```
+
+The following placeholders are supported for endpoints of type SSH:
+- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
+- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)
### 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
@@ -1601,7 +1627,6 @@ endpoints:
- "[CERTIFICATE_EXPIRATION] > 48h"
```
-
### Monitoring an endpoint using TLS
Monitoring endpoints using SSL/TLS encryption, such as LDAP over TLS, can help detect certificate expiration:
```yaml
diff --git a/client/client.go b/client/client.go
index 1388fea4..f782bd75 100644
--- a/client/client.go
+++ b/client/client.go
@@ -3,6 +3,7 @@ package client
import (
"crypto/tls"
"crypto/x509"
+ "encoding/json"
"errors"
"fmt"
"golang.org/x/net/websocket"
@@ -17,6 +18,7 @@ import (
"github.com/TwiN/whois"
"github.com/ishidawataru/sctp"
ping "github.com/prometheus-community/pro-bing"
+ "golang.org/x/crypto/ssh"
)
var (
@@ -161,6 +163,74 @@ func CanPerformTLS(address string, config *Config) (connected bool, certificate
return true, verifiedChains[0][0], nil
}
+// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
+// using the SSH protocol.
+func CanCreateSSHConnection(address, username, password string, config *Config) (bool, *ssh.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 ssh, format must be host:port")
+ }
+ address = addressAndPort[0]
+ port = addressAndPort[1]
+ } else {
+ port = "22"
+ }
+
+ cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ User: username,
+ Auth: []ssh.AuthMethod{
+ ssh.Password(password),
+ },
+ Timeout: config.Timeout,
+ })
+ if err != nil {
+ return false, nil, err
+ }
+
+ return true, cli, nil
+}
+
+// ExecuteSSHCommand executes a command to an address using the SSH protocol.
+func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
+ type Body struct {
+ Command string `json:"command"`
+ }
+
+ defer sshClient.Close()
+
+ var b Body
+ if err := json.Unmarshal([]byte(body), &b); err != nil {
+ return false, 0, err
+ }
+
+ sess, err := sshClient.NewSession()
+ if err != nil {
+ return false, 0, err
+ }
+
+ err = sess.Start(b.Command)
+ if err != nil {
+ return false, 0, err
+ }
+
+ defer sess.Close()
+
+ err = sess.Wait()
+ if err == nil {
+ return true, 0, nil
+ }
+
+ e, ok := err.(*ssh.ExitError)
+ if !ok {
+ return false, 0, err
+ }
+
+ return true, e.ExitStatus(), 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.yaml b/config.yaml
index ee49818f..7fa022ea 100644
--- a/config.yaml
+++ b/config.yaml
@@ -1,4 +1,17 @@
endpoints:
+ - name: ssh
+ group: core
+ url: "ssh://example.org"
+ ssh:
+ username: "example"
+ password: "example"
+ body: |
+ {
+ "command": "uptime"
+ }
+ interval: 1m
+ conditions:
+ - "[STATUS] == 0"
- name: front-end
group: core
url: "https://twin.sh/health"
diff --git a/core/endpoint.go b/core/endpoint.go
index 866f1cd8..54acba2e 100644
--- a/core/endpoint.go
+++ b/core/endpoint.go
@@ -17,6 +17,7 @@ import (
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core/ui"
"github.com/TwiN/gatus/v5/util"
+ "golang.org/x/crypto/ssh"
)
type EndpointType string
@@ -43,6 +44,7 @@ const (
EndpointTypeTLS EndpointType = "TLS"
EndpointTypeHTTP EndpointType = "HTTP"
EndpointTypeWS EndpointType = "WEBSOCKET"
+ EndpointTypeSSH EndpointType = "SSH"
EndpointTypeUNKNOWN EndpointType = "UNKNOWN"
)
@@ -70,6 +72,10 @@ var (
// This is because the free whois service we are using should not be abused, especially considering the fact that
// the data takes a while to be updated.
ErrInvalidEndpointIntervalForDomainExpirationPlaceholder = errors.New("the minimum interval for an endpoint with a condition using the " + DomainExpirationPlaceholder + " placeholder is 300s (5m)")
+ // ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
+ ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each endpoint with SSH")
+ // ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
+ ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each endpoint with SSH")
)
// Endpoint is the configuration of a monitored
@@ -121,6 +127,27 @@ type Endpoint struct {
// NumberOfSuccessesInARow is the number of successful evaluations in a row
NumberOfSuccessesInARow int `yaml:"-"`
+
+ // SSH is the configuration of SSH monitoring.
+ SSH *SSH `yaml:"ssh,omitempty"`
+}
+
+type SSH struct {
+ // Username is the username to use when connecting to the SSH server.
+ Username string `yaml:"username,omitempty"`
+ // Password is the password to use when connecting to the SSH server.
+ Password string `yaml:"password,omitempty"`
+}
+
+// Validate validates the endpoint
+func (s *SSH) ValidateAndSetDefaults() error {
+ if s.Username == "" {
+ return ErrEndpointWithoutSSHUsername
+ }
+ if s.Password == "" {
+ return ErrEndpointWithoutSSHPassword
+ }
+ return nil
}
// IsEnabled returns whether the endpoint is enabled or not
@@ -152,6 +179,8 @@ func (endpoint Endpoint) Type() EndpointType {
return EndpointTypeHTTP
case strings.HasPrefix(endpoint.URL, "ws://") || strings.HasPrefix(endpoint.URL, "wss://"):
return EndpointTypeWS
+ case strings.HasPrefix(endpoint.URL, "ssh://"):
+ return EndpointTypeSSH
default:
return EndpointTypeUNKNOWN
}
@@ -228,6 +257,9 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error {
if err != nil {
return err
}
+ if endpoint.SSH != nil {
+ return endpoint.SSH.ValidateAndSetDefaults()
+ }
return nil
}
@@ -350,6 +382,19 @@ func (endpoint *Endpoint) call(result *Result) {
result.AddError(err.Error())
return
}
+ } else if endpointType == EndpointTypeSSH {
+ var cli *ssh.Client
+ result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(endpoint.URL, "ssh://"), endpoint.SSH.Username, endpoint.SSH.Password, endpoint.ClientConfig)
+ if err != nil {
+ result.AddError(err.Error())
+ return
+ }
+ result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, endpoint.Body, endpoint.ClientConfig)
+ if err != nil {
+ result.AddError(err.Error())
+ return
+ }
+ result.Duration = time.Since(startTime)
} else {
response, err = client.GetHTTPClient(endpoint.ClientConfig).Do(request)
result.Duration = time.Since(startTime)
diff --git a/core/endpoint_test.go b/core/endpoint_test.go
index 82a90ccb..65832497 100644
--- a/core/endpoint_test.go
+++ b/core/endpoint_test.go
@@ -256,6 +256,7 @@ func TestEndpoint_Type(t *testing.T) {
type args struct {
URL string
DNS *DNS
+ SSH *SSH
}
tests := []struct {
args args
@@ -325,6 +326,16 @@ func TestEndpoint_Type(t *testing.T) {
},
want: EndpointTypeWS,
},
+ {
+ args: args{
+ URL: "ssh://example.com:22",
+ SSH: &SSH{
+ Username: "root",
+ Password: "password",
+ },
+ },
+ want: EndpointTypeSSH,
+ },
{
args: args{
URL: "invalid://example.org",
@@ -454,6 +465,52 @@ func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) {
}
}
+func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
+ tests := []struct {
+ name string
+ username string
+ password string
+ expectedErr error
+ }{
+ {
+ name: "fail when has no user",
+ username: "",
+ password: "password",
+ expectedErr: ErrEndpointWithoutSSHUsername,
+ },
+ {
+ name: "fail when has no password",
+ username: "username",
+ password: "",
+ expectedErr: ErrEndpointWithoutSSHPassword,
+ },
+ {
+ name: "success when all fields are set",
+ username: "username",
+ password: "password",
+ expectedErr: nil,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ endpoint := &Endpoint{
+ Name: "ssh-test",
+ URL: "https://example.com",
+ SSH: &SSH{
+ Username: test.username,
+ Password: test.password,
+ },
+ Conditions: []Condition{Condition("[STATUS] == 0")},
+ }
+ err := endpoint.ValidateAndSetDefaults()
+ if err != test.expectedErr {
+ t.Errorf("expected error %v, got %v", test.expectedErr, err)
+ }
+ })
+ }
+}
+
func TestEndpoint_ValidateAndSetDefaultsWithSimpleErrors(t *testing.T) {
scenarios := []struct {
endpoint *Endpoint
@@ -680,6 +737,55 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) {
}
}
+func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
+ tests := []struct {
+ name string
+ endpoint Endpoint
+ conditions []Condition
+ success bool
+ }{
+ {
+ name: "ssh-success",
+ endpoint: Endpoint{
+ Name: "ssh-success",
+ URL: "ssh://localhost",
+ SSH: &SSH{
+ Username: "test",
+ Password: "test",
+ },
+ Body: "{ \"command\": \"uptime\" }",
+ },
+ conditions: []Condition{Condition("[STATUS] == 0")},
+ success: true,
+ },
+ {
+ name: "ssh-failure",
+ endpoint: Endpoint{
+ Name: "ssh-failure",
+ URL: "ssh://localhost",
+ SSH: &SSH{
+ Username: "test",
+ Password: "test",
+ },
+ Body: "{ \"command\": \"uptime\" }",
+ },
+ conditions: []Condition{Condition("[STATUS] == 1")},
+ success: false,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ test.endpoint.ValidateAndSetDefaults()
+ test.endpoint.Conditions = test.conditions
+ result := test.endpoint.EvaluateHealth()
+ if result.Success != test.success {
+ t.Errorf("Expected success to be %v, but was %v", test.success, result.Success)
+ }
+ })
+ }
+}
+
func TestIntegrationEvaluateHealthForICMP(t *testing.T) {
endpoint := Endpoint{
Name: "icmp-test",