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",