From 05565e3d0acdd1592cc3822bec580d4efb427920 Mon Sep 17 00:00:00 2001 From: Henry Barreto <68656e72792e6261727265746f@gmail.com> Date: Sat, 23 Sep 2023 14:37:24 -0300 Subject: [PATCH] feat(SSH): Add support for SSH endpoint (#473) * feat(SSH): Add support for SSH endpoint This commit adds support for SSH endpoint monitoring. Users can now configure an endpoint to be monitored using an SSH command by prefixing the endpoint's URL with ssh:\\. The configuration options for an SSH endpoint include the username, password, and command to be executed on the remote server. In addition, two placeholders are supported for SSH endpoints: [CONNECTED] and [STATUS]. This commit also updates the README to include instructions on how to configure SSH endpoints and the placeholders that can be used in their conditions. The README has been updated to include the new SSH-related options in the endpoints[] configuration object. Here's a summary of the changes made in this commit: Added support for SSH endpoint monitoring Updated the documentation to include instructions on how to configure SSH endpoints and the placeholders that can be used in their conditions --- README.md | 27 ++++++++++- client/client.go | 70 ++++++++++++++++++++++++++++ config.yaml | 13 ++++++ core/endpoint.go | 45 ++++++++++++++++++ core/endpoint_test.go | 106 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 1 deletion(-) 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",