2021-10-23 22:47:12 +02:00
package core
import (
"bytes"
"crypto/x509"
"encoding/json"
"errors"
2022-12-06 07:37:05 +01:00
"fmt"
2021-12-03 07:44:17 +01:00
"io"
2021-10-23 22:47:12 +02:00
"net"
"net/http"
"net/url"
"strings"
"time"
2022-12-06 07:41:09 +01:00
"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"
2023-09-23 19:37:24 +02:00
"golang.org/x/crypto/ssh"
2021-10-23 22:47:12 +02:00
)
2022-05-17 03:10:45 +02:00
type EndpointType string
2021-10-23 22:47:12 +02:00
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"
2022-05-17 03:10:45 +02:00
EndpointTypeDNS EndpointType = "DNS"
EndpointTypeTCP EndpointType = "TCP"
2022-11-10 01:22:13 +01:00
EndpointTypeSCTP EndpointType = "SCTP"
EndpointTypeUDP EndpointType = "UDP"
2022-05-17 03:10:45 +02:00
EndpointTypeICMP EndpointType = "ICMP"
EndpointTypeSTARTTLS EndpointType = "STARTTLS"
EndpointTypeTLS EndpointType = "TLS"
EndpointTypeHTTP EndpointType = "HTTP"
2023-08-09 04:12:37 +02:00
EndpointTypeWS EndpointType = "WEBSOCKET"
2023-09-23 19:37:24 +02:00
EndpointTypeSSH EndpointType = "SSH"
2022-09-02 02:59:09 +02:00
EndpointTypeUNKNOWN EndpointType = "UNKNOWN"
2021-10-23 22:47:12 +02:00
)
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" )
2021-12-12 22:28:24 +01:00
// 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 \\" )
2022-09-02 02:59:09 +02:00
// ErrUnknownEndpointType is the error with which Gatus will panic if an endpoint has an unknown type
ErrUnknownEndpointType = errors . New ( "unknown endpoint type" )
2022-09-16 03:23:14 +02:00
2022-12-06 07:37:05 +01:00
// 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>'" )
2022-09-16 03:23:14 +02:00
// 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)" )
2023-09-23 19:37:24 +02:00
// 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" )
2021-10-23 22:47:12 +02:00
)
// 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
2022-06-14 01:15:30 +02:00
Conditions [ ] Condition ` yaml:"conditions" `
2021-10-23 22:47:12 +02:00
// Alerts is the alerting configuration for the endpoint in case of failure
2021-10-24 21:03:41 +02:00
Alerts [ ] * alert . Alert ` yaml:"alerts,omitempty" `
2021-10-23 22:47:12 +02:00
// ClientConfig is the configuration of the client used to communicate with the endpoint's target
2021-10-24 21:03:41 +02:00
ClientConfig * client . Config ` yaml:"client,omitempty" `
2021-10-23 22:47:12 +02:00
// UIConfig is the configuration for the UI
2021-10-24 21:03:41 +02:00
UIConfig * ui . Config ` yaml:"ui,omitempty" `
2021-10-23 22:47:12 +02:00
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
2021-10-24 21:03:41 +02:00
NumberOfFailuresInARow int ` yaml:"-" `
2021-10-23 22:47:12 +02:00
// NumberOfSuccessesInARow is the number of successful evaluations in a row
2021-10-24 21:03:41 +02:00
NumberOfSuccessesInARow int ` yaml:"-" `
2023-09-23 19:37:24 +02:00
// 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" `
}
2023-09-29 00:35:18 +02:00
// ValidateAndSetDefaults validates the endpoint
2023-09-23 19:37:24 +02:00
func ( s * SSH ) ValidateAndSetDefaults ( ) error {
if s . Username == "" {
return ErrEndpointWithoutSSHUsername
}
if s . Password == "" {
return ErrEndpointWithoutSSHPassword
}
return nil
2021-10-23 22:47:12 +02:00
}
// IsEnabled returns whether the endpoint is enabled or not
func ( endpoint Endpoint ) IsEnabled ( ) bool {
if endpoint . Enabled == nil {
return true
}
return * endpoint . Enabled
}
2022-05-17 03:10:45 +02:00
// 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
2022-11-10 01:22:13 +01:00
case strings . HasPrefix ( endpoint . URL , "sctp://" ) :
return EndpointTypeSCTP
case strings . HasPrefix ( endpoint . URL , "udp://" ) :
return EndpointTypeUDP
2022-05-17 03:10:45 +02:00
case strings . HasPrefix ( endpoint . URL , "icmp://" ) :
return EndpointTypeICMP
case strings . HasPrefix ( endpoint . URL , "starttls://" ) :
return EndpointTypeSTARTTLS
case strings . HasPrefix ( endpoint . URL , "tls://" ) :
return EndpointTypeTLS
2022-09-02 02:59:09 +02:00
case strings . HasPrefix ( endpoint . URL , "http://" ) || strings . HasPrefix ( endpoint . URL , "https://" ) :
2022-05-17 03:10:45 +02:00
return EndpointTypeHTTP
2023-08-09 04:12:37 +02:00
case strings . HasPrefix ( endpoint . URL , "ws://" ) || strings . HasPrefix ( endpoint . URL , "wss://" ) :
return EndpointTypeWS
2023-09-23 19:37:24 +02:00
case strings . HasPrefix ( endpoint . URL , "ssh://" ) :
return EndpointTypeSSH
2022-09-02 02:59:09 +02:00
default :
return EndpointTypeUNKNOWN
2022-05-17 03:10:45 +02:00
}
}
2022-09-07 03:22:02 +02:00
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one
2021-10-23 22:47:12 +02:00
func ( endpoint * Endpoint ) ValidateAndSetDefaults ( ) error {
// Set default values
if endpoint . ClientConfig == nil {
endpoint . ClientConfig = client . GetDefaultConfig ( )
} else {
2022-03-10 03:05:57 +01:00
if err := endpoint . ClientConfig . ValidateAndSetDefaults ( ) ; err != nil {
2022-03-10 02:53:51 +01:00
return err
}
2021-10-23 22:47:12 +02:00
}
if endpoint . UIConfig == nil {
endpoint . UIConfig = ui . GetDefaultConfig ( )
2022-08-11 03:05:34 +02:00
} else {
if err := endpoint . UIConfig . ValidateAndSetDefaults ( ) ; err != nil {
return err
}
2021-10-23 22:47:12 +02:00
}
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 {
2021-12-12 22:28:24 +01:00
if err := endpointAlert . ValidateAndSetDefaults ( ) ; err != nil {
return err
2021-10-23 22:47:12 +02:00
}
}
if len ( endpoint . Name ) == 0 {
return ErrEndpointWithNoName
}
2021-12-12 22:28:24 +01:00
if strings . ContainsAny ( endpoint . Name , "\"\\" ) || strings . ContainsAny ( endpoint . Group , "\"\\" ) {
return ErrEndpointWithInvalidNameOrGroup
}
2021-10-23 22:47:12 +02:00
if len ( endpoint . URL ) == 0 {
return ErrEndpointWithNoURL
}
if len ( endpoint . Conditions ) == 0 {
return ErrEndpointWithNoCondition
}
2022-12-06 07:37:05 +01:00
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 )
2022-09-16 03:23:14 +02:00
}
}
2021-10-23 22:47:12 +02:00
if endpoint . DNS != nil {
return endpoint . DNS . validateAndSetDefault ( )
}
2022-09-02 02:59:09 +02:00
if endpoint . Type ( ) == EndpointTypeUNKNOWN {
return ErrUnknownEndpointType
}
2021-10-23 22:47:12 +02:00
// 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
}
2023-09-23 19:37:24 +02:00
if endpoint . SSH != nil {
return endpoint . SSH . ValidateAndSetDefaults ( )
}
2021-10-23 22:47:12 +02:00
return nil
}
2021-12-12 22:28:24 +01:00
// 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
}
2021-10-23 22:47:12 +02:00
// 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 { } }
2022-09-07 03:22:02 +02:00
// 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 )
}
2022-11-16 03:35:22 +01:00
// 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)
2021-10-23 22:47:12 +02:00
if len ( result . Errors ) == 0 {
endpoint . call ( result )
} else {
result . Success = false
}
2022-11-16 03:35:22 +01:00
// Evaluate the conditions
2021-10-23 22:47:12 +02:00
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
2022-06-16 23:53:03 +02:00
if endpoint . UIConfig . HideURL {
for errIdx , errorString := range result . Errors {
result . Errors [ errIdx ] = strings . ReplaceAll ( errorString , endpoint . URL , "<redacted>" )
}
}
2021-10-23 22:47:12 +02:00
if endpoint . UIConfig . HideHostname {
2022-03-16 01:17:57 +01:00
for errIdx , errorString := range result . Errors {
2022-03-16 01:51:59 +01:00
result . Errors [ errIdx ] = strings . ReplaceAll ( errorString , result . Hostname , "<redacted>" )
2022-03-16 01:17:57 +01:00
}
2021-10-23 22:47:12 +02:00
result . Hostname = ""
}
return result
}
func ( endpoint * Endpoint ) getIP ( result * Result ) {
2022-11-16 03:35:22 +01:00
if ips , err := net . LookupIP ( result . Hostname ) ; err != nil {
2021-10-23 22:47:12 +02:00
result . AddError ( err . Error ( ) )
return
2022-09-07 03:22:02 +02:00
} else {
2022-11-16 03:35:22 +01:00
result . IP = ips [ 0 ] . String ( )
2022-09-07 03:22:02 +02:00
}
}
2021-10-23 22:47:12 +02:00
func ( endpoint * Endpoint ) call ( result * Result ) {
var request * http . Request
var response * http . Response
var err error
var certificate * x509 . Certificate
2022-05-17 03:10:45 +02:00
endpointType := endpoint . Type ( )
if endpointType == EndpointTypeHTTP {
2021-10-23 22:47:12 +02:00
request = endpoint . buildHTTPRequest ( )
}
startTime := time . Now ( )
2022-05-17 03:10:45 +02:00
if endpointType == EndpointTypeDNS {
2021-10-23 22:47:12 +02:00
endpoint . DNS . query ( endpoint . URL , result )
result . Duration = time . Since ( startTime )
2022-05-17 03:10:45 +02:00
} else if endpointType == EndpointTypeSTARTTLS || endpointType == EndpointTypeTLS {
if endpointType == EndpointTypeSTARTTLS {
2021-10-23 22:47:12 +02:00
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 )
2022-05-17 03:10:45 +02:00
} else if endpointType == EndpointTypeTCP {
2021-10-23 22:47:12 +02:00
result . Connected = client . CanCreateTCPConnection ( strings . TrimPrefix ( endpoint . URL , "tcp://" ) , endpoint . ClientConfig )
result . Duration = time . Since ( startTime )
2022-11-10 01:22:13 +01:00
} 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 )
2022-05-17 03:10:45 +02:00
} else if endpointType == EndpointTypeICMP {
2021-10-23 22:47:12 +02:00
result . Connected , result . Duration = client . Ping ( strings . TrimPrefix ( endpoint . URL , "icmp://" ) , endpoint . ClientConfig )
2023-08-09 04:12:37 +02:00
} else if endpointType == EndpointTypeWS {
2023-09-29 00:35:18 +02:00
result . Connected , result . Body , err = client . QueryWebSocket ( endpoint . URL , endpoint . Body , endpoint . ClientConfig )
2023-08-17 03:48:57 +02:00
if err != nil {
result . AddError ( err . Error ( ) )
return
}
2023-09-29 00:35:18 +02:00
result . Duration = time . Since ( startTime )
2023-09-23 19:37:24 +02:00
} 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 )
2021-10-23 22:47:12 +02:00
} 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
2023-03-15 01:02:31 +01:00
// Only read the Body if there's a condition that uses the BodyPlaceholder
2021-10-23 22:47:12 +02:00
if endpoint . needsToReadBody ( ) {
2023-03-15 01:02:31 +01:00
result . Body , err = io . ReadAll ( response . Body )
2021-10-23 22:47:12 +02:00
if err != nil {
2022-09-07 03:22:02 +02:00
result . AddError ( "error reading response body:" + err . Error ( ) )
2021-10-23 22:47:12 +02:00
}
}
}
}
2023-08-05 00:30:15 +02:00
// 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 ( )
}
}
2021-10-23 22:47:12 +02:00
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
}
2023-03-15 01:02:31 +01:00
// needsToReadBody checks if there's any condition that requires the response Body to be read
2021-10-23 22:47:12 +02:00
func ( endpoint * Endpoint ) needsToReadBody ( ) bool {
for _ , condition := range endpoint . Conditions {
if condition . hasBodyPlaceholder ( ) {
return true
}
}
return false
}
2022-09-07 03:22:02 +02:00
// 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
}