419 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			419 lines
		
	
	
		
			14 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"
 | |
| )
 | |
| 
 | |
| 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"
 | |
| 	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)")
 | |
| )
 | |
| 
 | |
| // 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:"-"`
 | |
| }
 | |
| 
 | |
| // 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
 | |
| 	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
 | |
| 	}
 | |
| 	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()
 | |
| 	// No need to keep the body after the endpoint has been evaluated
 | |
| 	result.body = nil
 | |
| 	// 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 {
 | |
| 		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())
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| 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
 | |
| }
 |