Files
gatus/config/endpoint/endpoint.go
T
Anand HegdeandTwiN 884f64d8e6 fix(maintenance): validate maintenance windows for external, DNS and SSH endpoints (#1804)
* fix(maintenance): validate maintenance windows for external, DNS and SSH endpoints

ExternalEndpoint.ValidateAndSetDefaults never called
maintenance.Config.ValidateAndSetDefaults, so the window's start time and
timezone were never parsed. IsUnderMaintenance then treated every window as
starting at midnight in the server's local timezone, so alerts were still
sent during the configured window.

Endpoint.ValidateAndSetDefaults had the same problem for DNS and SSH
endpoints, which returned before reaching the maintenance window loop. The
loop now runs before those early returns.

Fixes #1551

Made by an AI agent (Claude Code, model Claude Opus 5).

* test(api): assert external endpoint maintenance window suppresses alerting

Pushes a failing result for an external endpoint whose maintenance window
(Europe/Berlin) is currently active and checks that alerting is skipped,
exercising the same maintenance check CreateExternalEndpointResult uses.

---------

Co-authored-by: TwiN <twin@linux.com>
2026-09-30 09:32:30 -04:00

638 lines
21 KiB
Go

package endpoint
import (
"bytes"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"math/rand"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint/dns"
sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
"github.com/TwiN/gatus/v5/config/endpoint/ui"
"github.com/TwiN/gatus/v5/config/gontext"
"github.com/TwiN/gatus/v5/config/key"
"github.com/TwiN/gatus/v5/config/maintenance"
"golang.org/x/crypto/ssh"
)
type Type 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"
TypeDNS Type = "DNS"
TypeTCP Type = "TCP"
TypeSCTP Type = "SCTP"
TypeUDP Type = "UDP"
TypeICMP Type = "ICMP"
TypeSTARTTLS Type = "STARTTLS"
TypeTLS Type = "TLS"
TypeHTTP Type = "HTTP"
TypeGRPC Type = "GRPC"
TypeWS Type = "WEBSOCKET"
TypeSSH Type = "SSH"
TypeUNKNOWN Type = "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")
// 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 service to be 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"`
// 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"`
// ExtraLabels are key-value pairs that can be used to metric the endpoint
ExtraLabels map[string]string `yaml:"extra-labels,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"`
// MaintenanceWindow is the configuration for per-endpoint maintenance windows
MaintenanceWindows []*maintenance.Config `yaml:"maintenance-windows,omitempty"`
// DNSConfig is the configuration for DNS monitoring
DNSConfig *dns.Config `yaml:"dns,omitempty"`
// SSH is the configuration for SSH monitoring
SSHConfig *sshconfig.Config `yaml:"ssh,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:"-"`
// LastReminderSent is the time at which the last reminder was sent for this endpoint.
LastReminderSent time.Time `yaml:"-"`
///////////////////////
// SUITE-ONLY FIELDS //
///////////////////////
// Store is a map of values to extract from the result and store in the suite context
// This field is only used when the endpoint is part of a suite
Store map[string]string `yaml:"store,omitempty"`
// AlwaysRun defines whether to execute this endpoint even if previous endpoints in the suite failed
// This field is only used when the endpoint is part of a suite
AlwaysRun bool `yaml:"always-run,omitempty"`
}
// IsEnabled returns whether the endpoint is enabled or not
func (e *Endpoint) IsEnabled() bool {
if e.Enabled == nil {
return true
}
return *e.Enabled
}
// Type returns the endpoint type
func (e *Endpoint) Type() Type {
switch {
case e.DNSConfig != nil:
return TypeDNS
case strings.HasPrefix(e.URL, "tcp://"):
return TypeTCP
case strings.HasPrefix(e.URL, "sctp://"):
return TypeSCTP
case strings.HasPrefix(e.URL, "udp://"):
return TypeUDP
case strings.HasPrefix(e.URL, "icmp://"):
return TypeICMP
case strings.HasPrefix(e.URL, "starttls://"):
return TypeSTARTTLS
case strings.HasPrefix(e.URL, "tls://"):
return TypeTLS
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
return TypeHTTP
case strings.HasPrefix(e.URL, "grpc://") || strings.HasPrefix(e.URL, "grpcs://"):
return TypeGRPC
case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
return TypeWS
case strings.HasPrefix(e.URL, "ssh://"):
return TypeSSH
default:
return TypeUNKNOWN
}
}
// ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one
func (e *Endpoint) ValidateAndSetDefaults() error {
if err := validateEndpointNameGroupAndAlerts(e.Name, e.Group, e.Alerts); err != nil {
return err
}
if len(e.URL) == 0 {
return ErrEndpointWithNoURL
}
if e.ClientConfig == nil {
e.ClientConfig = client.GetDefaultConfig()
} else {
if err := e.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if e.UIConfig == nil {
e.UIConfig = ui.GetDefaultConfig()
} else {
if err := e.UIConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if e.Interval == 0 {
e.Interval = 1 * time.Minute
}
if len(e.Method) == 0 {
e.Method = http.MethodGet
}
if len(e.Headers) == 0 {
e.Headers = make(map[string]string)
}
// Automatically add user agent header if there isn't one specified in the endpoint configuration
if !hasHeader(e.Headers, UserAgentHeader) {
e.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 !hasHeader(e.Headers, ContentTypeHeader) && e.GraphQL {
e.Headers[ContentTypeHeader] = "application/json"
}
if len(e.Conditions) == 0 {
return ErrEndpointWithNoCondition
}
for _, c := range e.Conditions {
if e.Interval < 5*time.Minute && c.hasDomainExpirationPlaceholder() {
return ErrInvalidEndpointIntervalForDomainExpirationPlaceholder
}
if err := c.Validate(); err != nil {
return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err)
}
}
for _, maintenanceWindow := range e.MaintenanceWindows {
if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil {
return err
}
}
if e.DNSConfig != nil {
return e.DNSConfig.ValidateAndSetDefault()
}
if e.SSHConfig != nil {
return e.SSHConfig.Validate()
}
if e.Type() == TypeUNKNOWN {
return ErrUnknownEndpointType
}
// Make sure that the request can be created
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.getParsedBody())))
if err != nil {
return err
}
return nil
}
// DisplayName returns an identifier made up of the Name and, if not empty, the Group.
func (e *Endpoint) DisplayName() string {
if len(e.Group) > 0 {
return e.Group + "/" + e.Name
}
return e.Name
}
// Key returns the unique key for the Endpoint
func (e *Endpoint) Key() string {
return key.ConvertGroupAndNameToKey(e.Group, e.Name)
}
// 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 (e *Endpoint) Close() {
if e.Type() == TypeHTTP {
client.GetHTTPClient(e.ClientConfig).CloseIdleConnections()
}
}
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
func (e *Endpoint) EvaluateHealth() *Result {
return e.EvaluateHealthWithContext(nil)
}
// EvaluateHealthWithContext sends a request to the endpoint's URL with context support and evaluates the conditions
func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
result := &Result{Success: true, Errors: []string{}}
// Preprocess the endpoint with context if provided
processedEndpoint := e
if context != nil {
processedEndpoint = e.preprocessWithContext(result, context)
}
// Parse or extract hostname from URL
if processedEndpoint.DNSConfig != nil {
result.Hostname = strings.TrimSuffix(processedEndpoint.URL, ":53")
} else if processedEndpoint.Type() == TypeICMP {
// To handle IPv6 addresses, we need to handle the hostname differently here. This is to avoid, for instance,
// "1111:2222:3333::4444" being displayed as "1111:2222:3333:" because :4444 would be interpreted as a port.
result.Hostname = strings.TrimPrefix(processedEndpoint.URL, "icmp://")
} else {
urlObject, err := url.Parse(processedEndpoint.URL)
if err != nil {
result.AddError(err.Error())
} else {
result.Hostname = urlObject.Hostname()
result.port = urlObject.Port()
}
}
// Retrieve IP if necessary
if processedEndpoint.needsToRetrieveIP() {
processedEndpoint.getIP(result)
}
// Retrieve domain expiration if necessary
if processedEndpoint.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 {
processedEndpoint.call(result)
} else {
result.Success = false
}
// Evaluate the conditions
for _, condition := range processedEndpoint.Conditions {
success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, processedEndpoint.UIConfig.ResolveSuccessfulConditions, context)
if !success {
result.Success = false
}
}
result.Timestamp = time.Now()
// Clean up parameters that we don't need to keep in the results
if processedEndpoint.UIConfig.HideURL {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, processedEndpoint.URL, "<redacted>")
}
}
if processedEndpoint.UIConfig.HideHostname {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
}
result.Hostname = "" // remove it from the result so it doesn't get exposed
}
if processedEndpoint.UIConfig.HidePort && len(result.port) > 0 {
for errIdx, errorString := range result.Errors {
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.port, "<redacted>")
}
result.port = ""
}
if processedEndpoint.UIConfig.HideErrors {
result.Errors = nil
}
if processedEndpoint.UIConfig.HideConditions {
result.ConditionResults = nil
}
return result
}
// preprocessWithContext creates a copy of the endpoint with context placeholders replaced
func (e *Endpoint) preprocessWithContext(result *Result, context *gontext.Gontext) *Endpoint {
// Create a deep copy of the endpoint
processed := &Endpoint{}
*processed = *e
var err error
// Replace context placeholders in URL
if processed.URL, err = replaceContextPlaceholders(e.URL, context); err != nil {
result.AddError(err.Error())
}
// Replace context placeholders in Body
if processed.Body, err = replaceContextPlaceholders(e.Body, context); err != nil {
result.AddError(err.Error())
}
// Replace context placeholders in Headers
if e.Headers != nil {
processed.Headers = make(map[string]string)
for k, v := range e.Headers {
if processed.Headers[k], err = replaceContextPlaceholders(v, context); err != nil {
result.AddError(err.Error())
}
}
}
return processed
}
// replaceContextPlaceholders replaces [CONTEXT].path placeholders with actual values
func replaceContextPlaceholders(input string, ctx *gontext.Gontext) (string, error) {
if ctx == nil {
return input, nil
}
var contextErrors []string
contextRegex := regexp.MustCompile(`\[CONTEXT\]\.[\w\.\-]+`)
result := contextRegex.ReplaceAllStringFunc(input, func(match string) string {
// Extract the path after [CONTEXT].
path := strings.TrimPrefix(match, "[CONTEXT].")
value, err := ctx.Get(path)
if err != nil {
contextErrors = append(contextErrors, fmt.Sprintf("path '%s' not found", path))
return match // Keep placeholder for error reporting
}
return fmt.Sprintf("%v", value)
})
if len(contextErrors) > 0 {
return result, fmt.Errorf("context placeholder resolution failed: %s", strings.Join(contextErrors, ", "))
}
return result, nil
}
func (e *Endpoint) getParsedBody() string {
body := e.Body
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", e.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", e.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", e.URL)
randRegex, err := regexp.Compile(`\[RANDOM_STRING_\d+\]`)
if err == nil {
body = randRegex.ReplaceAllStringFunc(body, func(match string) string {
n, _ := strconv.Atoi(match[15 : len(match)-1])
if n > 8192 {
n = 8192 // Limit the length of the random string to 8192 bytes to avoid excessive memory usage
}
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}
return string(b)
})
}
return body
}
func (e *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 (e *Endpoint) call(result *Result) {
var request *http.Request
var response *http.Response
var err error
var certificate *x509.Certificate
endpointType := e.Type()
if endpointType == TypeHTTP {
request = e.buildHTTPRequest()
}
startTime := time.Now()
if endpointType == TypeDNS {
result.Connected, result.DNSRCode, result.Body, err = client.QueryDNS(e.DNSConfig.QueryType, e.DNSConfig.QueryName, e.URL)
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeSTARTTLS || endpointType == TypeTLS {
if endpointType == TypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig)
} else {
result.Connected, result.Body, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.getParsedBody(), e.ClientConfig)
}
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if endpointType == TypeTCP {
result.Connected, result.Body = client.CanCreateNetworkConnection("tcp", strings.TrimPrefix(e.URL, "tcp://"), e.getParsedBody(), e.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == TypeUDP {
result.Connected, result.Body = client.CanCreateNetworkConnection("udp", strings.TrimPrefix(e.URL, "udp://"), e.getParsedBody(), e.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == TypeSCTP {
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == TypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig)
} else if endpointType == TypeWS {
wsHeaders := map[string]string{}
if e.Headers != nil {
maps.Copy(wsHeaders, e.Headers)
}
if !hasHeader(wsHeaders, UserAgentHeader) {
wsHeaders[UserAgentHeader] = GatusUserAgent
}
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeSSH {
// If there's no username, password or private key specified, attempt to validate just the SSH banner
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Success = result.Connected
result.Duration = time.Since(startTime)
return
}
var cli *ssh.Client
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
var output []byte
result.Success, result.HTTPStatus, output, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
// Only store the output in result.Body if there's a condition that uses the BodyPlaceholder
if e.needsToReadBody() {
result.Body = output
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeGRPC {
useTLS := strings.HasPrefix(e.URL, "grpcs://")
address := strings.TrimPrefix(strings.TrimPrefix(e.URL, "grpcs://"), "grpc://")
connected, status, err, duration := client.PerformGRPCHealthCheck(address, useTLS, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Connected = connected
result.Duration = duration
if e.needsToReadBody() {
result.Body = []byte(fmt.Sprintf("{\"status\":\"%s\"}", status))
}
} else {
response, err = client.GetHTTPClient(e.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 e.needsToReadBody() {
result.Body, err = io.ReadAll(response.Body)
if err != nil {
result.AddError("error reading response body:" + err.Error())
}
}
}
}
func (e *Endpoint) buildHTTPRequest() *http.Request {
var bodyBuffer *bytes.Buffer
if e.GraphQL {
graphQlBody := map[string]string{
"query": e.getParsedBody(),
}
body, _ := json.Marshal(graphQlBody)
bodyBuffer = bytes.NewBuffer(body)
} else {
bodyBuffer = bytes.NewBuffer([]byte(e.getParsedBody()))
}
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
for k, v := range e.Headers {
request.Header.Set(k, v)
if strings.EqualFold(k, HostHeader) {
request.Host = v
}
}
return request
}
// needsToReadBody checks if there's any condition or store mapping that requires the response Body to be read
func (e *Endpoint) needsToReadBody() bool {
for _, condition := range e.Conditions {
if condition.hasBodyPlaceholder() {
return true
}
}
// Check store values for body placeholders
if e.Store != nil {
for _, value := range e.Store {
if strings.Contains(value, BodyPlaceholder) {
return true
}
}
}
return false
}
// needsToRetrieveDomainExpiration checks if there's any condition that requires a whois query to be performed
func (e *Endpoint) needsToRetrieveDomainExpiration() bool {
for _, condition := range e.Conditions {
if condition.hasDomainExpirationPlaceholder() {
return true
}
}
return false
}
// needsToRetrieveIP checks if there's any condition that requires an IP lookup
func (e *Endpoint) needsToRetrieveIP() bool {
for _, condition := range e.Conditions {
if condition.hasIPPlaceholder() {
return true
}
}
return false
}
// hasHeader checks if a header exists in the map using a case-insensitive lookup
func hasHeader(headers map[string]string, name string) bool {
for k := range headers {
if strings.EqualFold(k, name) {
return true
}
}
return false
}