mirror of
https://github.com/TwiN/gatus.git
synced 2024-11-25 01:13:40 +01:00
05565e3d0a
* 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
481 lines
16 KiB
Go
481 lines
16 KiB
Go
package core
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
|
"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
|
|
|
|
const (
|
|
// HostHeader is the name of the header used to specify the host
|
|
HostHeader = "Host"
|
|
|
|
// ContentTypeHeader is the name of the header used to specify the content type
|
|
ContentTypeHeader = "Content-Type"
|
|
|
|
// UserAgentHeader is the name of the header used to specify the request's user agent
|
|
UserAgentHeader = "User-Agent"
|
|
|
|
// GatusUserAgent is the default user agent that Gatus uses to send requests.
|
|
GatusUserAgent = "Gatus/1.0"
|
|
|
|
EndpointTypeDNS EndpointType = "DNS"
|
|
EndpointTypeTCP EndpointType = "TCP"
|
|
EndpointTypeSCTP EndpointType = "SCTP"
|
|
EndpointTypeUDP EndpointType = "UDP"
|
|
EndpointTypeICMP EndpointType = "ICMP"
|
|
EndpointTypeSTARTTLS EndpointType = "STARTTLS"
|
|
EndpointTypeTLS EndpointType = "TLS"
|
|
EndpointTypeHTTP EndpointType = "HTTP"
|
|
EndpointTypeWS EndpointType = "WEBSOCKET"
|
|
EndpointTypeSSH EndpointType = "SSH"
|
|
EndpointTypeUNKNOWN EndpointType = "UNKNOWN"
|
|
)
|
|
|
|
var (
|
|
// ErrEndpointWithNoCondition is the error with which Gatus will panic if an endpoint is configured with no conditions
|
|
ErrEndpointWithNoCondition = errors.New("you must specify at least one condition per endpoint")
|
|
|
|
// ErrEndpointWithNoURL is the error with which Gatus will panic if an endpoint is configured with no url
|
|
ErrEndpointWithNoURL = errors.New("you must specify an url for each endpoint")
|
|
|
|
// ErrEndpointWithNoName is the error with which Gatus will panic if an endpoint is configured with no name
|
|
ErrEndpointWithNoName = errors.New("you must specify a name for each endpoint")
|
|
|
|
// ErrEndpointWithInvalidNameOrGroup is the error with which Gatus will panic if an endpoint has an invalid character where it shouldn't
|
|
ErrEndpointWithInvalidNameOrGroup = errors.New("endpoint name and group must not have \" or \\")
|
|
|
|
// ErrUnknownEndpointType is the error with which Gatus will panic if an endpoint has an unknown type
|
|
ErrUnknownEndpointType = errors.New("unknown endpoint type")
|
|
|
|
// ErrInvalidConditionFormat is the error with which Gatus will panic if a condition has an invalid format
|
|
ErrInvalidConditionFormat = errors.New("invalid condition format: does not match '<VALUE> <COMPARATOR> <VALUE>'")
|
|
|
|
// ErrInvalidEndpointIntervalForDomainExpirationPlaceholder is the error with which Gatus will panic if an endpoint
|
|
// has both an interval smaller than 5 minutes and a condition with DomainExpirationPlaceholder.
|
|
// 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
|
|
type Endpoint struct {
|
|
// Enabled defines whether to enable the monitoring of the endpoint
|
|
Enabled *bool `yaml:"enabled,omitempty"`
|
|
|
|
// Name of the endpoint. Can be anything.
|
|
Name string `yaml:"name"`
|
|
|
|
// Group the endpoint is a part of. Used for grouping multiple endpoints together on the front end.
|
|
Group string `yaml:"group,omitempty"`
|
|
|
|
// URL to send the request to
|
|
URL string `yaml:"url"`
|
|
|
|
// DNS is the configuration of DNS monitoring
|
|
DNS *DNS `yaml:"dns,omitempty"`
|
|
|
|
// Method of the request made to the url of the endpoint
|
|
Method string `yaml:"method,omitempty"`
|
|
|
|
// Body of the request
|
|
Body string `yaml:"body,omitempty"`
|
|
|
|
// GraphQL is whether to wrap the body in a query param ({"query":"$body"})
|
|
GraphQL bool `yaml:"graphql,omitempty"`
|
|
|
|
// Headers of the request
|
|
Headers map[string]string `yaml:"headers,omitempty"`
|
|
|
|
// Interval is the duration to wait between every status check
|
|
Interval time.Duration `yaml:"interval,omitempty"`
|
|
|
|
// Conditions used to determine the health of the endpoint
|
|
Conditions []Condition `yaml:"conditions"`
|
|
|
|
// Alerts is the alerting configuration for the endpoint in case of failure
|
|
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
|
|
|
|
// ClientConfig is the configuration of the client used to communicate with the endpoint's target
|
|
ClientConfig *client.Config `yaml:"client,omitempty"`
|
|
|
|
// UIConfig is the configuration for the UI
|
|
UIConfig *ui.Config `yaml:"ui,omitempty"`
|
|
|
|
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
|
|
NumberOfFailuresInARow int `yaml:"-"`
|
|
|
|
// 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
|
|
func (endpoint Endpoint) IsEnabled() bool {
|
|
if endpoint.Enabled == nil {
|
|
return true
|
|
}
|
|
return *endpoint.Enabled
|
|
}
|
|
|
|
// Type returns the endpoint type
|
|
func (endpoint Endpoint) Type() EndpointType {
|
|
switch {
|
|
case endpoint.DNS != nil:
|
|
return EndpointTypeDNS
|
|
case strings.HasPrefix(endpoint.URL, "tcp://"):
|
|
return EndpointTypeTCP
|
|
case strings.HasPrefix(endpoint.URL, "sctp://"):
|
|
return EndpointTypeSCTP
|
|
case strings.HasPrefix(endpoint.URL, "udp://"):
|
|
return EndpointTypeUDP
|
|
case strings.HasPrefix(endpoint.URL, "icmp://"):
|
|
return EndpointTypeICMP
|
|
case strings.HasPrefix(endpoint.URL, "starttls://"):
|
|
return EndpointTypeSTARTTLS
|
|
case strings.HasPrefix(endpoint.URL, "tls://"):
|
|
return EndpointTypeTLS
|
|
case strings.HasPrefix(endpoint.URL, "http://") || strings.HasPrefix(endpoint.URL, "https://"):
|
|
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
|
|
}
|
|
}
|
|
|
|
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one
|
|
func (endpoint *Endpoint) ValidateAndSetDefaults() error {
|
|
// Set default values
|
|
if endpoint.ClientConfig == nil {
|
|
endpoint.ClientConfig = client.GetDefaultConfig()
|
|
} else {
|
|
if err := endpoint.ClientConfig.ValidateAndSetDefaults(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if endpoint.UIConfig == nil {
|
|
endpoint.UIConfig = ui.GetDefaultConfig()
|
|
} else {
|
|
if err := endpoint.UIConfig.ValidateAndSetDefaults(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if endpoint.Interval == 0 {
|
|
endpoint.Interval = 1 * time.Minute
|
|
}
|
|
if len(endpoint.Method) == 0 {
|
|
endpoint.Method = http.MethodGet
|
|
}
|
|
if len(endpoint.Headers) == 0 {
|
|
endpoint.Headers = make(map[string]string)
|
|
}
|
|
// Automatically add user agent header if there isn't one specified in the endpoint configuration
|
|
if _, userAgentHeaderExists := endpoint.Headers[UserAgentHeader]; !userAgentHeaderExists {
|
|
endpoint.Headers[UserAgentHeader] = GatusUserAgent
|
|
}
|
|
// Automatically add "Content-Type: application/json" header if there's no Content-Type set
|
|
// and endpoint.GraphQL is set to true
|
|
if _, contentTypeHeaderExists := endpoint.Headers[ContentTypeHeader]; !contentTypeHeaderExists && endpoint.GraphQL {
|
|
endpoint.Headers[ContentTypeHeader] = "application/json"
|
|
}
|
|
for _, endpointAlert := range endpoint.Alerts {
|
|
if err := endpointAlert.ValidateAndSetDefaults(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(endpoint.Name) == 0 {
|
|
return ErrEndpointWithNoName
|
|
}
|
|
if strings.ContainsAny(endpoint.Name, "\"\\") || strings.ContainsAny(endpoint.Group, "\"\\") {
|
|
return ErrEndpointWithInvalidNameOrGroup
|
|
}
|
|
if len(endpoint.URL) == 0 {
|
|
return ErrEndpointWithNoURL
|
|
}
|
|
if len(endpoint.Conditions) == 0 {
|
|
return ErrEndpointWithNoCondition
|
|
}
|
|
for _, c := range endpoint.Conditions {
|
|
if endpoint.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() {
|
|
return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder
|
|
}
|
|
if err := c.Validate(); err != nil {
|
|
return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err)
|
|
}
|
|
}
|
|
if endpoint.DNS != nil {
|
|
return endpoint.DNS.validateAndSetDefault()
|
|
}
|
|
if endpoint.Type() == EndpointTypeUNKNOWN {
|
|
return ErrUnknownEndpointType
|
|
}
|
|
// Make sure that the request can be created
|
|
_, err := http.NewRequest(endpoint.Method, endpoint.URL, bytes.NewBuffer([]byte(endpoint.Body)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if endpoint.SSH != nil {
|
|
return endpoint.SSH.ValidateAndSetDefaults()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DisplayName returns an identifier made up of the Name and, if not empty, the Group.
|
|
func (endpoint Endpoint) DisplayName() string {
|
|
if len(endpoint.Group) > 0 {
|
|
return endpoint.Group + "/" + endpoint.Name
|
|
}
|
|
return endpoint.Name
|
|
}
|
|
|
|
// Key returns the unique key for the Endpoint
|
|
func (endpoint Endpoint) Key() string {
|
|
return util.ConvertGroupAndEndpointNameToKey(endpoint.Group, endpoint.Name)
|
|
}
|
|
|
|
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
|
|
func (endpoint *Endpoint) EvaluateHealth() *Result {
|
|
result := &Result{Success: true, Errors: []string{}}
|
|
// Parse or extract hostname from URL
|
|
if endpoint.DNS != nil {
|
|
result.Hostname = strings.TrimSuffix(endpoint.URL, ":53")
|
|
} else {
|
|
urlObject, err := url.Parse(endpoint.URL)
|
|
if err != nil {
|
|
result.AddError(err.Error())
|
|
} else {
|
|
result.Hostname = urlObject.Hostname()
|
|
}
|
|
}
|
|
// Retrieve IP if necessary
|
|
if endpoint.needsToRetrieveIP() {
|
|
endpoint.getIP(result)
|
|
}
|
|
// Retrieve domain expiration if necessary
|
|
if endpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
|
|
var err error
|
|
if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil {
|
|
result.AddError(err.Error())
|
|
}
|
|
}
|
|
// Call the endpoint (if there's no errors)
|
|
if len(result.Errors) == 0 {
|
|
endpoint.call(result)
|
|
} else {
|
|
result.Success = false
|
|
}
|
|
// Evaluate the conditions
|
|
for _, condition := range endpoint.Conditions {
|
|
success := condition.evaluate(result, endpoint.UIConfig.DontResolveFailedConditions)
|
|
if !success {
|
|
result.Success = false
|
|
}
|
|
}
|
|
result.Timestamp = time.Now()
|
|
// Clean up parameters that we don't need to keep in the results
|
|
if endpoint.UIConfig.HideURL {
|
|
for errIdx, errorString := range result.Errors {
|
|
result.Errors[errIdx] = strings.ReplaceAll(errorString, endpoint.URL, "<redacted>")
|
|
}
|
|
}
|
|
if endpoint.UIConfig.HideHostname {
|
|
for errIdx, errorString := range result.Errors {
|
|
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
|
|
}
|
|
result.Hostname = ""
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (endpoint *Endpoint) getIP(result *Result) {
|
|
if ips, err := net.LookupIP(result.Hostname); err != nil {
|
|
result.AddError(err.Error())
|
|
return
|
|
} else {
|
|
result.IP = ips[0].String()
|
|
}
|
|
}
|
|
|
|
func (endpoint *Endpoint) call(result *Result) {
|
|
var request *http.Request
|
|
var response *http.Response
|
|
var err error
|
|
var certificate *x509.Certificate
|
|
endpointType := endpoint.Type()
|
|
if endpointType == EndpointTypeHTTP {
|
|
request = endpoint.buildHTTPRequest()
|
|
}
|
|
startTime := time.Now()
|
|
if endpointType == EndpointTypeDNS {
|
|
endpoint.DNS.query(endpoint.URL, result)
|
|
result.Duration = time.Since(startTime)
|
|
} else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS {
|
|
if endpointType == EndpointTypeSTARTTLS {
|
|
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(endpoint.URL, "starttls://"), endpoint.ClientConfig)
|
|
} else {
|
|
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(endpoint.URL, "tls://"), endpoint.ClientConfig)
|
|
}
|
|
if err != nil {
|
|
result.AddError(err.Error())
|
|
return
|
|
}
|
|
result.Duration = time.Since(startTime)
|
|
result.CertificateExpiration = time.Until(certificate.NotAfter)
|
|
} else if endpointType == EndpointTypeTCP {
|
|
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(endpoint.URL, "tcp://"), endpoint.ClientConfig)
|
|
result.Duration = time.Since(startTime)
|
|
} else if endpointType == EndpointTypeUDP {
|
|
result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(endpoint.URL, "udp://"), endpoint.ClientConfig)
|
|
result.Duration = time.Since(startTime)
|
|
} else if endpointType == EndpointTypeSCTP {
|
|
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(endpoint.URL, "sctp://"), endpoint.ClientConfig)
|
|
result.Duration = time.Since(startTime)
|
|
} else if endpointType == EndpointTypeICMP {
|
|
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(endpoint.URL, "icmp://"), endpoint.ClientConfig)
|
|
} else if endpointType == EndpointTypeWS {
|
|
result.Connected, result.Body, err = client.QueryWebSocket(endpoint.URL, endpoint.ClientConfig, endpoint.Body)
|
|
result.Duration = time.Since(startTime)
|
|
if err != nil {
|
|
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)
|
|
if err != nil {
|
|
result.AddError(err.Error())
|
|
return
|
|
}
|
|
defer response.Body.Close()
|
|
if response.TLS != nil && len(response.TLS.PeerCertificates) > 0 {
|
|
certificate = response.TLS.PeerCertificates[0]
|
|
result.CertificateExpiration = time.Until(certificate.NotAfter)
|
|
}
|
|
result.HTTPStatus = response.StatusCode
|
|
result.Connected = response.StatusCode > 0
|
|
// Only read the Body if there's a condition that uses the BodyPlaceholder
|
|
if endpoint.needsToReadBody() {
|
|
result.Body, err = io.ReadAll(response.Body)
|
|
if err != nil {
|
|
result.AddError("error reading response body:" + err.Error())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors
|
|
// on configuration reload.
|
|
// More context on https://github.com/TwiN/gatus/issues/536
|
|
func (endpoint *Endpoint) Close() {
|
|
if endpoint.Type() == EndpointTypeHTTP {
|
|
client.GetHTTPClient(endpoint.ClientConfig).CloseIdleConnections()
|
|
}
|
|
}
|
|
|
|
func (endpoint *Endpoint) buildHTTPRequest() *http.Request {
|
|
var bodyBuffer *bytes.Buffer
|
|
if endpoint.GraphQL {
|
|
graphQlBody := map[string]string{
|
|
"query": endpoint.Body,
|
|
}
|
|
body, _ := json.Marshal(graphQlBody)
|
|
bodyBuffer = bytes.NewBuffer(body)
|
|
} else {
|
|
bodyBuffer = bytes.NewBuffer([]byte(endpoint.Body))
|
|
}
|
|
request, _ := http.NewRequest(endpoint.Method, endpoint.URL, bodyBuffer)
|
|
for k, v := range endpoint.Headers {
|
|
request.Header.Set(k, v)
|
|
if k == HostHeader {
|
|
request.Host = v
|
|
}
|
|
}
|
|
return request
|
|
}
|
|
|
|
// needsToReadBody checks if there's any condition that requires the response Body to be read
|
|
func (endpoint *Endpoint) needsToReadBody() bool {
|
|
for _, condition := range endpoint.Conditions {
|
|
if condition.hasBodyPlaceholder() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed
|
|
func (endpoint *Endpoint) needsToRetrieveDomainExpiration() bool {
|
|
for _, condition := range endpoint.Conditions {
|
|
if condition.hasDomainExpirationPlaceholder() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// needsToRetrieveIP checks if there's any condition that requires an IP lookup
|
|
func (endpoint *Endpoint) needsToRetrieveIP() bool {
|
|
for _, condition := range endpoint.Conditions {
|
|
if condition.hasIPPlaceholder() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|