From 0437b280b1aeb42a3a24ddf8e5acb5f3c63e9e14 Mon Sep 17 00:00:00 2001 From: TwiN Date: Fri, 5 Apr 2024 18:09:59 -0400 Subject: [PATCH 1/9] refactor: Move SSH outside of endpoint.go --- core/dns_test.go | 8 ++--- core/endpoint.go | 73 +++++++++++++++---------------------------- core/endpoint_test.go | 66 ++++++++++++++++++++++++++++---------- core/ssh.go | 29 +++++++++++++++++ core/ssh_test.go | 25 +++++++++++++++ 5 files changed, 131 insertions(+), 70 deletions(-) create mode 100644 core/ssh.go create mode 100644 core/ssh_test.go diff --git a/core/dns_test.go b/core/dns_test.go index a81cc3fe2..f888bc92c 100644 --- a/core/dns_test.go +++ b/core/dns_test.go @@ -103,26 +103,24 @@ func TestIntegrationQuery(t *testing.T) { } } -func TestEndpoint_ValidateAndSetDefaultsWithNoDNSQueryName(t *testing.T) { - defer func() { recover() }() +func TestDNS_validateAndSetDefault(t *testing.T) { dns := &DNS{ QueryType: "A", QueryName: "", } err := dns.validateAndSetDefault() if err == nil { - t.Fatal("Should've returned an error because endpoint's dns didn't have a query name, which is a mandatory field for dns") + t.Error("Should've returned an error because endpoint's dns didn't have a query name, which is a mandatory field for dns") } } func TestEndpoint_ValidateAndSetDefaultsWithInvalidDNSQueryType(t *testing.T) { - defer func() { recover() }() dns := &DNS{ QueryType: "B", QueryName: "example.com", } err := dns.validateAndSetDefault() if err == nil { - t.Fatal("Should've returned an error because endpoint's dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...") + t.Error("Should've returned an error because endpoint's dns query type is invalid, it needs to be a valid query name like A, AAAA, CNAME...") } } diff --git a/core/endpoint.go b/core/endpoint.go index 5d6fd9674..aab5f90f2 100644 --- a/core/endpoint.go +++ b/core/endpoint.go @@ -72,13 +72,9 @@ 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 +// 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"` @@ -95,6 +91,9 @@ type Endpoint struct { // DNS is the configuration of DNS monitoring DNS *DNS `yaml:"dns,omitempty"` + // SSH is the configuration of SSH monitoring. + SSH *SSH `yaml:"ssh,omitempty"` + // Method of the request made to the url of the endpoint Method string `yaml:"method,omitempty"` @@ -127,27 +126,6 @@ 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"` -} - -// ValidateAndSetDefaults 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 @@ -188,7 +166,15 @@ func (endpoint *Endpoint) Type() EndpointType { // 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 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 endpoint.ClientConfig == nil { endpoint.ClientConfig = client.GetDefaultConfig() } else { @@ -226,15 +212,6 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error { 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 } @@ -249,6 +226,9 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error { if endpoint.DNS != nil { return endpoint.DNS.validateAndSetDefault() } + if endpoint.SSH != nil { + return endpoint.SSH.validate() + } if endpoint.Type() == EndpointTypeUNKNOWN { return ErrUnknownEndpointType } @@ -257,9 +237,6 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error { if err != nil { return err } - if endpoint.SSH != nil { - return endpoint.SSH.ValidateAndSetDefaults() - } return nil } @@ -276,6 +253,15 @@ func (endpoint *Endpoint) Key() string { return util.ConvertGroupAndEndpointNameToKey(endpoint.Group, endpoint.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 (endpoint *Endpoint) Close() { + if endpoint.Type() == EndpointTypeHTTP { + client.GetHTTPClient(endpoint.ClientConfig).CloseIdleConnections() + } +} + // 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{}} @@ -419,15 +405,6 @@ func (endpoint *Endpoint) call(result *Result) { } } -// 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 { diff --git a/core/endpoint_test.go b/core/endpoint_test.go index ef9be7874..3c54f5a56 100644 --- a/core/endpoint_test.go +++ b/core/endpoint_test.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/tls" "crypto/x509" + "errors" "io" "net/http" "strings" @@ -123,6 +124,7 @@ func TestEndpoint(t *testing.T) { Name: "website-health", URL: "https://twin.sh/health", Conditions: []Condition{"[DOMAIN_EXPIRATION] > 100h"}, + Interval: 5 * time.Minute, }, ExpectedResult: &Result{ Success: true, @@ -195,7 +197,10 @@ func TestEndpoint(t *testing.T) { } else { client.InjectHTTPClient(nil) } - scenario.Endpoint.ValidateAndSetDefaults() + err := scenario.Endpoint.ValidateAndSetDefaults() + if err != nil { + t.Error("did not expect an error, got", err) + } result := scenario.Endpoint.EvaluateHealth() if result.Success != scenario.ExpectedResult.Success { t.Errorf("Expected success to be %v, got %v", scenario.ExpectedResult.Success, result.Success) @@ -430,7 +435,10 @@ func TestEndpoint_ValidateAndSetDefaultsWithClientConfig(t *testing.T) { Timeout: 0, }, } - endpoint.ValidateAndSetDefaults() + err := endpoint.ValidateAndSetDefaults() + if err != nil { + t.Fatal("did not expect an error, got", err) + } if endpoint.ClientConfig == nil { t.Error("client configuration should've been set to the default configuration") } else { @@ -466,7 +474,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithDNS(t *testing.T) { } func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) { - tests := []struct { + scenarios := []struct { name string username string password string @@ -492,20 +500,20 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) { }, } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { endpoint := &Endpoint{ Name: "ssh-test", URL: "https://example.com", SSH: &SSH{ - Username: test.username, - Password: test.password, + Username: scenario.username, + Password: scenario.password, }, Conditions: []Condition{Condition("[STATUS] == 0")}, } err := endpoint.ValidateAndSetDefaults() - if err != test.expectedErr { - t.Errorf("expected error %v, got %v", test.expectedErr, err) + if !errors.Is(err, scenario.expectedErr) { + t.Errorf("expected error %v, got %v", scenario.expectedErr, err) } }) } @@ -575,7 +583,10 @@ func TestEndpoint_buildHTTPRequest(t *testing.T) { URL: "https://twin.sh/health", Conditions: []Condition{condition}, } - endpoint.ValidateAndSetDefaults() + err := endpoint.ValidateAndSetDefaults() + if err != nil { + t.Fatal("did not expect an error, got", err) + } request := endpoint.buildHTTPRequest() if request.Method != "GET" { t.Error("request.Method should've been GET, but was", request.Method) @@ -598,7 +609,10 @@ func TestEndpoint_buildHTTPRequestWithCustomUserAgent(t *testing.T) { "User-Agent": "Test/2.0", }, } - endpoint.ValidateAndSetDefaults() + err := endpoint.ValidateAndSetDefaults() + if err != nil { + t.Fatal("did not expect an error, got", err) + } request := endpoint.buildHTTPRequest() if request.Method != "GET" { t.Error("request.Method should've been GET, but was", request.Method) @@ -622,7 +636,10 @@ func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) { "Host": "example.com", }, } - endpoint.ValidateAndSetDefaults() + err := endpoint.ValidateAndSetDefaults() + if err != nil { + t.Fatal("did not expect an error, got", err) + } request := endpoint.buildHTTPRequest() if request.Method != "POST" { t.Error("request.Method should've been POST, but was", request.Method) @@ -649,7 +666,10 @@ func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) { } }`, } - endpoint.ValidateAndSetDefaults() + err := endpoint.ValidateAndSetDefaults() + if err != nil { + t.Fatal("did not expect an error, got", err) + } request := endpoint.buildHTTPRequest() if request.Method != "POST" { t.Error("request.Method should've been POST, but was", request.Method) @@ -671,7 +691,10 @@ func TestIntegrationEvaluateHealth(t *testing.T) { URL: "https://twin.sh/health", Conditions: []Condition{condition, bodyCondition}, } - endpoint.ValidateAndSetDefaults() + err := endpoint.ValidateAndSetDefaults() + if err != nil { + t.Fatal("did not expect an error, got", err) + } result := endpoint.EvaluateHealth() if !result.ConditionResults[0].Success { t.Errorf("Condition '%s' should have been a success", condition) @@ -699,7 +722,10 @@ func TestIntegrationEvaluateHealthWithErrorAndHideURL(t *testing.T) { HideURL: true, }, } - endpoint.ValidateAndSetDefaults() + err := endpoint.ValidateAndSetDefaults() + if err != nil { + t.Fatal("did not expect an error, got", err) + } result := endpoint.EvaluateHealth() if result.Success { t.Error("Because one of the conditions was invalid, result.Success should have been false") @@ -724,7 +750,10 @@ func TestIntegrationEvaluateHealthForDNS(t *testing.T) { }, Conditions: []Condition{conditionSuccess, conditionBody}, } - endpoint.ValidateAndSetDefaults() + err := endpoint.ValidateAndSetDefaults() + if err != nil { + t.Fatal("did not expect an error, got", err) + } result := endpoint.EvaluateHealth() if !result.ConditionResults[0].Success { t.Errorf("Conditions '%s' and '%s' should have been a success", conditionSuccess, conditionBody) @@ -792,7 +821,10 @@ func TestIntegrationEvaluateHealthForICMP(t *testing.T) { URL: "icmp://127.0.0.1", Conditions: []Condition{"[CONNECTED] == true"}, } - endpoint.ValidateAndSetDefaults() + err := endpoint.ValidateAndSetDefaults() + if err != nil { + t.Fatal("did not expect an error, got", err) + } result := endpoint.EvaluateHealth() if !result.ConditionResults[0].Success { t.Errorf("Conditions '%s' should have been a success", endpoint.Conditions[0]) diff --git a/core/ssh.go b/core/ssh.go new file mode 100644 index 000000000..80720e8a2 --- /dev/null +++ b/core/ssh.go @@ -0,0 +1,29 @@ +package core + +import ( + "errors" +) + +var ( + // 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 SSH endpoint") + + // 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 SSH endpoint") +) + +type SSH struct { + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` +} + +// validateAndSetDefaults validates the endpoint +func (s *SSH) validate() error { + if len(s.Username) == 0 { + return ErrEndpointWithoutSSHUsername + } + if len(s.Password) == 0 { + return ErrEndpointWithoutSSHPassword + } + return nil +} diff --git a/core/ssh_test.go b/core/ssh_test.go new file mode 100644 index 000000000..15e704336 --- /dev/null +++ b/core/ssh_test.go @@ -0,0 +1,25 @@ +package core + +import ( + "errors" + "testing" +) + +func TestSSH_validate(t *testing.T) { + ssh := &SSH{} + if err := ssh.validate(); err == nil { + t.Error("expected an error") + } else if !errors.Is(err, ErrEndpointWithoutSSHUsername) { + t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHUsername, err) + } + ssh.Username = "username" + if err := ssh.validate(); err == nil { + t.Error("expected an error") + } else if !errors.Is(err, ErrEndpointWithoutSSHPassword) { + t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err) + } + ssh.Password = "password" + if err := ssh.validate(); err != nil { + t.Errorf("expected no error, got '%v'", err) + } +} From df5248c54592ecfc36039c056b7f70c999660634 Mon Sep 17 00:00:00 2001 From: TwiN Date: Sat, 6 Apr 2024 22:35:20 -0400 Subject: [PATCH 2/9] refactor: Use pointers for Alert receivers --- alerting/alert/alert.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/alerting/alert/alert.go b/alerting/alert/alert.go index acd5afe85..c1fbdcf87 100644 --- a/alerting/alert/alert.go +++ b/alerting/alert/alert.go @@ -71,7 +71,7 @@ func (alert *Alert) ValidateAndSetDefaults() error { } // GetDescription retrieves the description of the alert -func (alert Alert) GetDescription() string { +func (alert *Alert) GetDescription() string { if alert.Description == nil { return "" } @@ -80,7 +80,7 @@ func (alert Alert) GetDescription() string { // IsEnabled returns whether an alert is enabled or not // Returns true if not set -func (alert Alert) IsEnabled() bool { +func (alert *Alert) IsEnabled() bool { if alert.Enabled == nil { return true } @@ -88,7 +88,7 @@ func (alert Alert) IsEnabled() bool { } // IsSendingOnResolved returns whether an alert is sending on resolve or not -func (alert Alert) IsSendingOnResolved() bool { +func (alert *Alert) IsSendingOnResolved() bool { if alert.SendOnResolved == nil { return false } From 442e6a40d5cb26acbf83430543c89a29539bcd3a Mon Sep 17 00:00:00 2001 From: TwiN Date: Sat, 6 Apr 2024 22:37:36 -0400 Subject: [PATCH 3/9] feat: Implement push-based external endpoints Fixes #722 --- README.md | 171 ++++++++++++++++++++++++++--------- api/api.go | 2 + api/badge.go | 21 +++-- api/chart.go | 5 +- api/external_endpoint.go | 61 +++++++++++++ config/config.go | 26 ++++-- core/endpoint.go | 18 +--- core/endpoint_common.go | 32 +++++++ core/endpoint_common_test.go | 51 +++++++++++ core/external_endpoint.go | 89 ++++++++++++++++++ main.go | 3 + watchdog/watchdog.go | 5 +- 12 files changed, 404 insertions(+), 80 deletions(-) create mode 100644 api/external_endpoint.go create mode 100644 core/endpoint_common.go create mode 100644 core/endpoint_common_test.go create mode 100644 core/external_endpoint.go diff --git a/README.md b/README.md index 84f5b8ac2..245e4348e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ [![Follow TwiN](https://img.shields.io/github/followers/TwiN?label=Follow&style=social)](https://github.com/TwiN) - Gatus is a developer-oriented health dashboard that gives you the ability to monitor your services using HTTP, ICMP, TCP, and even DNS queries as well as evaluate the result of said queries by using a list of conditions on values like the status code, the response time, the certificate expiration, the body and many others. The icing on top is that each of these health @@ -46,6 +45,8 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Features](#features) - [Usage](#usage) - [Configuration](#configuration) + - [Endpoints](#endpoints) + - [External Endpoints](#external-endpoints) - [Conditions](#conditions) - [Placeholders](#placeholders) - [Functions](#functions) @@ -137,6 +138,7 @@ if no traffic makes it to your applications. This puts you in a situation where that will notify you about the degradation of your services rather than you reassuring them that you're working on fixing the issue before they even know about it. + ## Features The main features of Gatus are: @@ -151,6 +153,7 @@ The main features of Gatus are: ![Gatus dashboard conditions](.github/assets/dashboard-conditions.png) + ## Usage
@@ -208,11 +211,42 @@ If you want to test it locally, see [Docker](#docker). ## Configuration +| Parameter | Description | Default | +|:-----------------------------|:-------------------------------------------------------------------------------------------------------------------------------------|:---------------------------| +| `debug` | Whether to enable debug logs. | `false` | +| `metrics` | Whether to expose metrics at `/metrics`. | `false` | +| `storage` | [Storage configuration](#storage). | `{}` | +| `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` | +| `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` | +| `alerting` | [Alerting configuration](#alerting). | `{}` | +| `security` | [Security configuration](#security). | `{}` | +| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` | +| `skip-invalid-config-update` | Whether to ignore invalid configuration update.
See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` | +| `web` | Web configuration. | `{}` | +| `web.address` | Address to listen on. | `0.0.0.0` | +| `web.port` | Port to listen on. | `8080` | +| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` | +| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `` | +| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `` | +| `ui` | UI configuration. | `{}` | +| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` | +| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. | +| `ui.header` | Header at the top of the dashboard. | `Health Status` | +| `ui.logo` | URL to the logo to display. | `""` | +| `ui.link` | Link to open when the logo is clicked. | `""` | +| `ui.buttons` | List of buttons to display below the header. | `[]` | +| `ui.buttons[].name` | Text to display on the button. | Required `""` | +| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` | +| `maintenance` | [Maintenance configuration](#maintenance). | `{}` | + + +### Endpoints +Endpoints are URLs, applications, or services that you want to monitor. Each endpoint has a list of conditions that are +evaluated on an interval that you define. If any condition fails, the endpoint is considered as unhealthy. +You can then configure alerts to be triggered when an endpoint is unhealthy once a certain threshold is reached. + | Parameter | Description | Default | |:------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------| -| `debug` | Whether to enable debug logs. | `false` | -| `metrics` | Whether to expose metrics at /metrics. | `false` | -| `storage` | [Storage configuration](#storage) | `{}` | | `endpoints` | List of endpoints to monitor. | Required `[]` | | `endpoints[].enabled` | Whether to monitor the endpoint. | `true` | | `endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` | @@ -225,43 +259,49 @@ If you want to test it locally, see [Docker](#docker). | `endpoints[].body` | Request body. | `""` | | `endpoints[].headers` | Request headers. | `{}` | | `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[].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` | -| `endpoints[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` | -| `endpoints[].alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` | -| `endpoints[].alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` | +| `endpoints[].ssh.username` | SSH username (e.g. example). | Required `""` | +| `endpoints[].ssh.password` | SSH password (e.g. password). | Required `""` | +| `endpoints[].alerts` | List of all alerts for a given endpoint.
See [Alerting](#alerting). | `[]` | | `endpoints[].client` | [Client configuration](#client-configuration). | `{}` | | `endpoints[].ui` | UI configuration at the endpoint level. | `{}` | | `endpoints[].ui.hide-hostname` | Whether to hide the hostname in the result. | `false` | | `endpoints[].ui.hide-url` | Whether to ensure the URL is not displayed in the results. Useful if the URL contains a token. | `false` | | `endpoints[].ui.dont-resolve-failed-conditions` | Whether to resolve failed conditions for the UI. | `false` | | `endpoints[].ui.badge.reponse-time` | List of response time thresholds. Each time a threshold is reached, the badge has a different color. | `[50, 200, 300, 500, 750]` | -| `alerting` | [Alerting configuration](#alerting). | `{}` | -| `security` | [Security configuration](#security). | `{}` | -| `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` | -| `skip-invalid-config-update` | Whether to ignore invalid configuration update.
See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` | -| `web` | Web configuration. | `{}` | -| `web.address` | Address to listen on. | `0.0.0.0` | -| `web.port` | Port to listen on. | `8080` | -| `web.read-buffer-size` | Buffer size for reading requests from a connection. Also limit for the maximum header size. | `8192` | -| `web.tls.certificate-file` | Optional public certificate file for TLS in PEM format. | `` | -| `web.tls.private-key-file` | Optional private key file for TLS in PEM format. | `` | -| `ui` | UI configuration. | `{}` | -| `ui.title` | [Title of the document](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/title). | `Health Dashboard ǀ Gatus` | -| `ui.description` | Meta description for the page. | `Gatus is an advanced...`. | -| `ui.header` | Header at the top of the dashboard. | `Health Status` | -| `ui.logo` | URL to the logo to display. | `""` | -| `ui.link` | Link to open when the logo is clicked. | `""` | -| `ui.buttons` | List of buttons to display below the header. | `[]` | -| `ui.buttons[].name` | Text to display on the button. | Required `""` | -| `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` | -| `maintenance` | [Maintenance configuration](#maintenance). | `{}` | + + +### External Endpoints +Unlike regular endpoints, external endpoints are not monitored by Gatus, but they are instead pushed programmatically. +This allows you to monitor anything you want, even when what you want to check lives in an environment that would not normally be accessible by Gatus. + +For instance: +- You can create your own agent that lives in a private network and pushes the status of your services to a publicly-exposed Gatus instance +- You can monitor services that are not supported by Gatus +- You can implement your own monitoring system while using Gatus as the dashboard + +| Parameter | Description | Default | +|:-------------------------------|:-----------------------------------------------------------------------------------------------------------------------|:--------------| +| `external-endpoints` | List of endpoints to monitor. | `[]` | +| `external-endpoints[].enabled` | Whether to monitor the endpoint. | `true` | +| `external-endpoints[].name` | Name of the endpoint. Can be anything. | Required `""` | +| `external-endpoints[].group` | Group name. Used to group multiple endpoints together on the dashboard.
See [Endpoint groups](#endpoint-groups). | `""` | +| `external-endpoints[].token` | Bearer token required to push status to. | Required `""` | +| `external-endpoints[].alerts` | List of all alerts for a given endpoint.
See [Alerting](#alerting). | `[]` | + +Example: +```yaml +external-endpoints: + - name: ext-ep-test + group: core + token: "potato" + alerts: + - type: discord + description: "healthcheck failed" + send-on-resolved: true +``` ### Conditions @@ -355,7 +395,7 @@ In order to support a wide range of environments, each monitored endpoint has a the client used to send the request. | Parameter | Description | Default | -| :------------------------------------- | :-------------------------------------------------------------------------- | :-------------- | +|:---------------------------------------|:----------------------------------------------------------------------------|:----------------| | `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` | | `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` | | `client.timeout` | Duration before timing out. | `10s` | @@ -441,10 +481,36 @@ endpoints: > 📝 Note that Gatus will use the [gcloud default credentials](https://cloud.google.com/docs/authentication/application-default-credentials) within its environment to generate the token. + ### Alerting Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each individual endpoints with configurable descriptions and thresholds. +Alerts are configured at the endpoint level like so: + +| Parameter | Description | Default | +|:-----------------------------|:-------------------------------------------------------------------------------|:--------------| +| `alerts` | List of all alerts for a given endpoint. | `[]` | +| `alerts[].type` | Type of alert.
See table below for all valid types. | Required `""` | +| `alerts[].enabled` | Whether to enable the alert. | `true` | +| `alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` | +| `alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` | +| `alerts[].send-on-resolved` | Whether to send a notification once a triggered alert is marked as resolved. | `false` | +| `alerts[].description` | Description of the alert. Will be included in the alert sent. | `""` | + +Here's an example of what an alert configuration might look like at the endpoint level: +```yaml +endpoints: + - name: example + url: "https://example.org" + conditions: + - "[STATUS] == 200" + alerts: + - type: slack + description: "healthcheck failed" + send-on-resolved: true +``` + > 📝 If an alerting provider is not properly configured, all alerts configured with the provider's type will be ignored. @@ -472,15 +538,15 @@ ignored. #### Configuring Discord alerts -| Parameter | Description | Default | -|:-------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| -| `alerting.discord` | Configuration for alerts of type `discord` | `{}` | -| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` | -| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` | -| `alerting.discord.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | -| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | -| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | -| `alerting.discord.overrides[].webhook-url` | Discord Webhook URL | `""` | +| Parameter | Description | Default | +|:-------------------------------------------|:-------------------------------------------------------------------------------------------|:------------------------------------| +| `alerting.discord` | Configuration for alerts of type `discord` | `{}` | +| `alerting.discord.webhook-url` | Discord Webhook URL | Required `""` | +| `alerting.discord.title` | Title of the notification | `":helmet_with_white_cross: Gatus"` | +| `alerting.discord.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.discord.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.discord.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.discord.overrides[].webhook-url` | Discord Webhook URL | `""` | ```yaml alerting: @@ -600,6 +666,7 @@ endpoints: ![GitHub alert](.github/assets/github-alerts.png) + #### Configuring GitLab alerts | Parameter | Description | Default | |:------------------------------------|:----------------------------------------------------------------------------------------------------------------|:--------------| @@ -995,6 +1062,7 @@ endpoints: description: "healthcheck failed" ``` + #### Configuring Slack alerts | Parameter | Description | Default | |:------------------------------------------|:-------------------------------------------------------------------------------------------|:--------------| @@ -1433,6 +1501,7 @@ security: Confused? Read [Securing Gatus with OIDC using Auth0](https://twin.sh/articles/56/securing-gatus-with-oidc-using-auth0). + ### TLS Encryption Gatus supports basic encryption with TLS. To enable this, certificate files in PEM format have to be provided. @@ -1445,6 +1514,7 @@ web: private-key-file: "private.key" ``` + ### Metrics To enable metrics, you must set `metrics` to `true`. Doing so will expose Prometheus-friendly metrics at the `/metrics` endpoint on the same port your application is configured to run on (`web.port`). @@ -1547,7 +1617,7 @@ helm repo add minicloudlabs https://minicloudlabs.github.io/helm-charts ``` To get more details, please check [chart's configuration](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#configuration) -and [helmfile example](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#helmfileyaml-example) +and [helm file example](https://github.com/minicloudlabs/helm-charts/tree/main/charts/gatus#helmfileyaml-example) ### Terraform @@ -1656,6 +1726,7 @@ This works for applications such as databases (Postgres, MySQL, etc.) and caches something at the given address listening to the given port, and that a connection to that address was successfully established. + ### Monitoring a UDP endpoint By prefixing `endpoints[].url` with `udp:\\`, you can monitor UDP endpoints at a very basic level: @@ -1672,6 +1743,7 @@ Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, ` This works for UDP based application. + ### Monitoring a SCTP endpoint By prefixing `endpoints[].url` with `sctp:\\`, you can monitor Stream Control Transmission Protocol (SCTP) endpoints at a very basic level: @@ -1688,6 +1760,7 @@ Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, ` This works for SCTP based application. + ### Monitoring a WebSocket endpoint By prefixing `endpoints[].url` with `ws://` or `wss://`, you can monitor WebSocket endpoints at a very basic level: @@ -1704,6 +1777,7 @@ endpoints: The `[BODY]` placeholder contains the output of the query, and `[CONNECTED]` shows whether the connection was successfully established. + ### Monitoring an endpoint using ICMP By prefixing `endpoints[].url` with `icmp:\\`, you can monitor endpoints at a very basic level using ICMP, or more commonly known as "ping" or "echo": @@ -1722,6 +1796,7 @@ You can specify a domain prefixed by `icmp://`, or an IP address prefixed by `ic If you run Gatus on Linux, please read the Linux section on https://github.com/prometheus-community/pro-bing#linux if you encounter any problems. + ### Monitoring an endpoint using DNS queries Defining a `dns` configuration in an endpoint will automatically mark said endpoint as an endpoint of type DNS: ```yaml @@ -1741,6 +1816,7 @@ 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 @@ -1764,6 +1840,7 @@ 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 will serve as a good initial indicator: @@ -1779,6 +1856,7 @@ 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 @@ -1957,6 +2035,7 @@ endpoints: ```
+ ### Proxy client configuration You can configure a proxy for the client to use by setting the `proxy-url` parameter in the client configuration. @@ -1971,6 +2050,7 @@ endpoints: - "[STATUS] == 200" ``` + ### How to fix 431 Request Header Fields Too Large error Depending on where your environment is deployed and what kind of middleware or reverse proxy sits in front of Gatus, you may run into this issue. This could be because the request headers are too large, e.g. big cookies. @@ -2113,5 +2193,6 @@ You can download Gatus as a binary using the following command: go install github.com/TwiN/gatus/v5@latest ``` + ### High level design overview ![Gatus diagram](.github/assets/gatus-diagram.jpg) diff --git a/api/api.go b/api/api.go index 942679ba3..4635708bc 100644 --- a/api/api.go +++ b/api/api.go @@ -76,6 +76,8 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App { unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge) unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg)) unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart) + // This endpoint requires authz with bearer token, so technically it is protected + unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg)) // SPA app.Get("/", SinglePageApplication(cfg.UI)) app.Get("/endpoints/:name", SinglePageApplication(cfg.UI)) diff --git a/api/badge.go b/api/badge.go index 13c27c031..287da3947 100644 --- a/api/badge.go +++ b/api/badge.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "fmt" "strconv" "strings" @@ -52,9 +53,9 @@ func UptimeBadge(c *fiber.Ctx) error { key := c.Params("key") uptime, err := store.Get().GetUptimeByKey(key, from, time.Now()) if err != nil { - if err == common.ErrEndpointNotFound { + if errors.Is(err, common.ErrEndpointNotFound) { return c.Status(404).SendString(err.Error()) - } else if err == common.ErrInvalidTimeRange { + } else if errors.Is(err, common.ErrInvalidTimeRange) { return c.Status(400).SendString(err.Error()) } return c.Status(500).SendString(err.Error()) @@ -68,7 +69,7 @@ func UptimeBadge(c *fiber.Ctx) error { // ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed. // // Valid values for :duration -> 7d, 24h, 1h -func ResponseTimeBadge(config *config.Config) fiber.Handler { +func ResponseTimeBadge(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { duration := c.Params("duration") var from time.Time @@ -85,9 +86,9 @@ func ResponseTimeBadge(config *config.Config) fiber.Handler { key := c.Params("key") averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now()) if err != nil { - if err == common.ErrEndpointNotFound { + if errors.Is(err, common.ErrEndpointNotFound) { return c.Status(404).SendString(err.Error()) - } else if err == common.ErrInvalidTimeRange { + } else if errors.Is(err, common.ErrInvalidTimeRange) { return c.Status(400).SendString(err.Error()) } return c.Status(500).SendString(err.Error()) @@ -95,7 +96,7 @@ func ResponseTimeBadge(config *config.Config) fiber.Handler { c.Set("Content-Type", "image/svg+xml") c.Set("Cache-Control", "no-cache, no-store, must-revalidate") c.Set("Expires", "0") - return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config)) + return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, cfg)) } } @@ -105,9 +106,9 @@ func HealthBadge(c *fiber.Ctx) error { pagingConfig := paging.NewEndpointStatusParams() status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1)) if err != nil { - if err == common.ErrEndpointNotFound { + if errors.Is(err, common.ErrEndpointNotFound) { return c.Status(404).SendString(err.Error()) - } else if err == common.ErrInvalidTimeRange { + } else if errors.Is(err, common.ErrInvalidTimeRange) { return c.Status(400).SendString(err.Error()) } return c.Status(500).SendString(err.Error()) @@ -131,9 +132,9 @@ func HealthBadgeShields(c *fiber.Ctx) error { pagingConfig := paging.NewEndpointStatusParams() status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1)) if err != nil { - if err == common.ErrEndpointNotFound { + if errors.Is(err, common.ErrEndpointNotFound) { return c.Status(404).SendString(err.Error()) - } else if err == common.ErrInvalidTimeRange { + } else if errors.Is(err, common.ErrInvalidTimeRange) { return c.Status(400).SendString(err.Error()) } return c.Status(500).SendString(err.Error()) diff --git a/api/chart.go b/api/chart.go index 3d3b76f03..3c8a07aef 100644 --- a/api/chart.go +++ b/api/chart.go @@ -1,6 +1,7 @@ package api import ( + "errors" "log" "math" "net/http" @@ -42,9 +43,9 @@ func ResponseTimeChart(c *fiber.Ctx) error { } hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now()) if err != nil { - if err == common.ErrEndpointNotFound { + if errors.Is(err, common.ErrEndpointNotFound) { return c.Status(404).SendString(err.Error()) - } else if err == common.ErrInvalidTimeRange { + } else if errors.Is(err, common.ErrInvalidTimeRange) { return c.Status(400).SendString(err.Error()) } return c.Status(500).SendString(err.Error()) diff --git a/api/external_endpoint.go b/api/external_endpoint.go new file mode 100644 index 000000000..0e0acbeac --- /dev/null +++ b/api/external_endpoint.go @@ -0,0 +1,61 @@ +package api + +import ( + "errors" + "log" + "strings" + "time" + + "github.com/TwiN/gatus/v5/config" + "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/storage/store" + "github.com/TwiN/gatus/v5/storage/store/common" + "github.com/TwiN/gatus/v5/watchdog" + "github.com/gofiber/fiber/v2" +) + +func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + // Check if the success query parameter is present + success, exists := c.Queries()["success"] + if !exists || (success != "true" && success != "false") { + return c.Status(400).SendString("missing or invalid success query parameter") + } + // Check if the authorization bearer token header is correct + authorizationHeader := string(c.Request().Header.Peek("Authorization")) + if !strings.HasPrefix(authorizationHeader, "Bearer ") { + return c.Status(401).SendString("invalid Authorization header") + } + token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer ")) + if len(token) == 0 { + return c.Status(401).SendString("bearer token must not be empty") + } + externalEndpoint := cfg.GetExternalEndpointByKey(c.Params("key")) + if externalEndpoint == nil { + return c.Status(404).SendString("not found") + } + // Persist the result in the storage + result := &core.Result{ + Timestamp: time.Now(), + Success: c.QueryBool("success"), + Errors: []string{}, + } + convertedEndpoint := externalEndpoint.ToEndpoint() + if err := store.Get().Insert(convertedEndpoint, result); err != nil { + if errors.Is(err, common.ErrEndpointNotFound) { + return c.Status(404).SendString(err.Error()) + } + log.Printf("[api.CreateExternalEndpointResult] Failed to insert result in storage: %s", err.Error()) + return c.Status(500).SendString(err.Error()) + } + log.Printf("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success) + // Check if an alert should be triggered or resolved + if !cfg.Maintenance.IsUnderMaintenance() { + watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting, cfg.Debug) + externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow + externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow + } + // Return the result + return c.Status(200).SendString("") + } +} diff --git a/config/config.go b/config/config.go index 1025865c9..824730b55 100644 --- a/config/config.go +++ b/config/config.go @@ -67,15 +67,18 @@ type Config struct { // Disabling this may lead to inaccurate response times DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"` - // Security Configuration for securing access to Gatus + // Security is the configuration for securing access to Gatus Security *security.Config `yaml:"security,omitempty"` - // Alerting Configuration for alerting + // Alerting is the configuration for alerting providers Alerting *alerting.Config `yaml:"alerting,omitempty"` - // Endpoints List of endpoints to monitor + // Endpoints is the list of endpoints to monitor Endpoints []*core.Endpoint `yaml:"endpoints,omitempty"` + // ExternalEndpoints is the list of all external endpoints + ExternalEndpoints []*core.ExternalEndpoint `yaml:"external-endpoints,omitempty"` + // Storage is the configuration for how the data is stored Storage *storage.Config `yaml:"storage,omitempty"` @@ -110,9 +113,20 @@ func (config *Config) GetEndpointByKey(key string) *core.Endpoint { return nil } +func (config *Config) GetExternalEndpointByKey(key string) *core.ExternalEndpoint { + // TODO: Should probably add a mutex here to prevent concurrent access + for i := 0; i < len(config.ExternalEndpoints); i++ { + ee := config.ExternalEndpoints[i] + if util.ConvertGroupAndEndpointNameToKey(ee.Group, ee.Name) == key { + return ee + } + } + return nil +} + // HasLoadedConfigurationBeenModified returns whether one of the file that the // configuration has been loaded from has been modified since it was last read -func (config Config) HasLoadedConfigurationBeenModified() bool { +func (config *Config) HasLoadedConfigurationBeenModified() bool { lastMod := config.lastFileModTime.Unix() fileInfo, err := os.Stat(config.configPath) if err != nil { @@ -125,7 +139,7 @@ func (config Config) HasLoadedConfigurationBeenModified() bool { } return nil }) - return err == errEarlyReturn + return errors.Is(err, errEarlyReturn) } return !fileInfo.ModTime().IsZero() && config.lastFileModTime.Unix() < fileInfo.ModTime().Unix() } @@ -135,7 +149,7 @@ func (config *Config) UpdateLastFileModTime() { config.lastFileModTime = time.Now() } -// LoadConfiguration loads the full configuration composed from the main configuration file +// LoadConfiguration loads the full configuration composed of the main configuration file // and all composed configuration files func LoadConfiguration(configPath string) (*Config, error) { var configBytes []byte diff --git a/core/endpoint.go b/core/endpoint.go index aab5f90f2..4e8e1592b 100644 --- a/core/endpoint.go +++ b/core/endpoint.go @@ -55,12 +55,6 @@ var ( // 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") @@ -166,11 +160,8 @@ func (endpoint *Endpoint) Type() EndpointType { // ValidateAndSetDefaults validates the endpoint's configuration and sets the default value of args that have one func (endpoint *Endpoint) ValidateAndSetDefaults() error { - if len(endpoint.Name) == 0 { - return ErrEndpointWithNoName - } - if strings.ContainsAny(endpoint.Name, "\"\\") || strings.ContainsAny(endpoint.Group, "\"\\") { - return ErrEndpointWithInvalidNameOrGroup + if err := validateEndpointNameGroupAndAlerts(endpoint.Name, endpoint.Group, endpoint.Alerts); err != nil { + return err } if len(endpoint.URL) == 0 { return ErrEndpointWithNoURL @@ -207,11 +198,6 @@ func (endpoint *Endpoint) ValidateAndSetDefaults() error { 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.Conditions) == 0 { return ErrEndpointWithNoCondition } diff --git a/core/endpoint_common.go b/core/endpoint_common.go new file mode 100644 index 000000000..f99e55019 --- /dev/null +++ b/core/endpoint_common.go @@ -0,0 +1,32 @@ +package core + +import ( + "errors" + "strings" + + "github.com/TwiN/gatus/v5/alerting/alert" +) + +var ( + // 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 \\") +) + +// validateEndpointNameGroupAndAlerts validates the name, group and alerts of an endpoint +func validateEndpointNameGroupAndAlerts(name, group string, alerts []*alert.Alert) error { + if len(name) == 0 { + return ErrEndpointWithNoName + } + if strings.ContainsAny(name, "\"\\") || strings.ContainsAny(group, "\"\\") { + return ErrEndpointWithInvalidNameOrGroup + } + for _, endpointAlert := range alerts { + if err := endpointAlert.ValidateAndSetDefaults(); err != nil { + return err + } + } + return nil +} diff --git a/core/endpoint_common_test.go b/core/endpoint_common_test.go new file mode 100644 index 000000000..e1c631780 --- /dev/null +++ b/core/endpoint_common_test.go @@ -0,0 +1,51 @@ +package core + +import ( + "errors" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" +) + +func TestValidateEndpointNameGroupAndAlerts(t *testing.T) { + scenarios := []struct { + name string + group string + alerts []*alert.Alert + expectedErr error + }{ + { + name: "n", + group: "g", + alerts: []*alert.Alert{{Type: "slack"}}, + }, + { + name: "n", + alerts: []*alert.Alert{{Type: "slack"}}, + }, + { + group: "g", + alerts: []*alert.Alert{{Type: "slack"}}, + expectedErr: ErrEndpointWithNoName, + }, + { + name: "\"", + alerts: []*alert.Alert{{Type: "slack"}}, + expectedErr: ErrEndpointWithInvalidNameOrGroup, + }, + { + name: "n", + group: "\\", + alerts: []*alert.Alert{{Type: "slack"}}, + expectedErr: ErrEndpointWithInvalidNameOrGroup, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := validateEndpointNameGroupAndAlerts(scenario.name, scenario.group, scenario.alerts) + if !errors.Is(err, scenario.expectedErr) { + t.Errorf("expected error to be %v but got %v", scenario.expectedErr, err) + } + }) + } +} diff --git a/core/external_endpoint.go b/core/external_endpoint.go new file mode 100644 index 000000000..709deea5f --- /dev/null +++ b/core/external_endpoint.go @@ -0,0 +1,89 @@ +package core + +import ( + "errors" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/util" +) + +var ( + // ErrExternalEndpointWithNoToken is the error with which Gatus will panic if an external endpoint is configured without a token. + ErrExternalEndpointWithNoToken = errors.New("you must specify a token for each external endpoint") +) + +// ExternalEndpoint is an endpoint whose result is pushed from outside Gatus, which means that +// said endpoints are not monitored by Gatus itself; Gatus only displays their results and takes +// care of alerting +type ExternalEndpoint 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"` + + // Token is the bearer token that must be provided through the Authorization header to push results to the endpoint + Token string `yaml:"token,omitempty"` + + // Alerts is the alerting configuration for the endpoint in case of failure + Alerts []*alert.Alert `yaml:"alerts,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:"-"` +} + +// ValidateAndSetDefaults validates the ExternalEndpoint and sets the default values +func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error { + if err := validateEndpointNameGroupAndAlerts(externalEndpoint.Name, externalEndpoint.Group, externalEndpoint.Alerts); err != nil { + return err + } + if len(externalEndpoint.Token) == 0 { + return ErrExternalEndpointWithNoToken + } + for _, externalEndpointAlert := range externalEndpoint.Alerts { + if err := externalEndpointAlert.ValidateAndSetDefaults(); err != nil { + return err + } + } + return nil +} + +// IsEnabled returns whether the endpoint is enabled or not +func (externalEndpoint *ExternalEndpoint) IsEnabled() bool { + if externalEndpoint.Enabled == nil { + return true + } + return *externalEndpoint.Enabled +} + +// DisplayName returns an identifier made up of the Name and, if not empty, the Group. +func (externalEndpoint *ExternalEndpoint) DisplayName() string { + if len(externalEndpoint.Group) > 0 { + return externalEndpoint.Group + "/" + externalEndpoint.Name + } + return externalEndpoint.Name +} + +// Key returns the unique key for the Endpoint +func (externalEndpoint *ExternalEndpoint) Key() string { + return util.ConvertGroupAndEndpointNameToKey(externalEndpoint.Group, externalEndpoint.Name) +} + +// ToEndpoint converts the ExternalEndpoint to an Endpoint +func (externalEndpoint *ExternalEndpoint) ToEndpoint() *Endpoint { + endpoint := &Endpoint{ + Enabled: externalEndpoint.Enabled, + Name: externalEndpoint.Name, + Group: externalEndpoint.Group, + Alerts: externalEndpoint.Alerts, + NumberOfFailuresInARow: externalEndpoint.NumberOfFailuresInARow, + NumberOfSuccessesInARow: externalEndpoint.NumberOfSuccessesInARow, + } + return endpoint +} diff --git a/main.go b/main.go index a9fa01a91..c5994ac6a 100644 --- a/main.go +++ b/main.go @@ -83,6 +83,9 @@ func initializeStorage(cfg *config.Config) { for _, endpoint := range cfg.Endpoints { keys = append(keys, endpoint.Key()) } + for _, externalEndpoint := range cfg.ExternalEndpoints { + keys = append(keys, externalEndpoint.Key()) + } numberOfEndpointStatusesDeleted := store.Get().DeleteAllEndpointStatusesNotInKeys(keys) if numberOfEndpointStatusesDeleted > 0 { log.Printf("[main.initializeStorage] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted) diff --git a/watchdog/watchdog.go b/watchdog/watchdog.go index af1a16218..2557b413b 100644 --- a/watchdog/watchdog.go +++ b/watchdog/watchdog.go @@ -50,6 +50,9 @@ func monitor(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenan execute(endpoint, alertingConfig, maintenanceConfig, connectivityConfig, disableMonitoringLock, enabledMetrics, debug) } } + // Just in case somebody wandered all the way to here and wonders, "what about ExternalEndpoints?" + // Alerting is checked every time an external endpoint is pushed to Gatus, so they're not monitored + // periodically like they are for normal endpoints. } func execute(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenanceConfig *maintenance.Config, connectivityConfig *connectivity.Config, disableMonitoringLock, enabledMetrics, debug bool) { @@ -91,7 +94,7 @@ func execute(endpoint *core.Endpoint, alertingConfig *alerting.Config, maintenan // UpdateEndpointStatuses updates the slice of endpoint statuses func UpdateEndpointStatuses(endpoint *core.Endpoint, result *core.Result) { if err := store.Get().Insert(endpoint, result); err != nil { - log.Println("[watchdog.UpdateEndpointStatuses] Failed to insert data in storage:", err.Error()) + log.Println("[watchdog.UpdateEndpointStatuses] Failed to insert result in storage:", err.Error()) } } From 05babf1366c28d2c75217e0be577a22075a054f9 Mon Sep 17 00:00:00 2001 From: TwiN Date: Sat, 6 Apr 2024 23:48:23 -0400 Subject: [PATCH 4/9] Fix failing tests --- alerting/alert/alert_test.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/alerting/alert/alert_test.go b/alerting/alert/alert_test.go index 0df0f1e2a..5f51eab1e 100644 --- a/alerting/alert/alert_test.go +++ b/alerting/alert/alert_test.go @@ -1,6 +1,7 @@ package alert import ( + "errors" "testing" ) @@ -38,7 +39,7 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) { } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { - if err := scenario.alert.ValidateAndSetDefaults(); err != scenario.expectedError { + if err := scenario.alert.ValidateAndSetDefaults(); !errors.Is(err, scenario.expectedError) { t.Errorf("expected error %v, got %v", scenario.expectedError, err) } if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold { @@ -52,34 +53,34 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) { } func TestAlert_IsEnabled(t *testing.T) { - if !(Alert{Enabled: nil}).IsEnabled() { + if !(&Alert{Enabled: nil}).IsEnabled() { t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil") } - if value := false; (Alert{Enabled: &value}).IsEnabled() { + if value := false; (&Alert{Enabled: &value}).IsEnabled() { t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false") } - if value := true; !(Alert{Enabled: &value}).IsEnabled() { + if value := true; !(&Alert{Enabled: &value}).IsEnabled() { t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true") } } func TestAlert_GetDescription(t *testing.T) { - if (Alert{Description: nil}).GetDescription() != "" { + if (&Alert{Description: nil}).GetDescription() != "" { t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil") } - if value := "description"; (Alert{Description: &value}).GetDescription() != value { + if value := "description"; (&Alert{Description: &value}).GetDescription() != value { t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'") } } func TestAlert_IsSendingOnResolved(t *testing.T) { - if (Alert{SendOnResolved: nil}).IsSendingOnResolved() { + if (&Alert{SendOnResolved: nil}).IsSendingOnResolved() { t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil") } - if value := false; (Alert{SendOnResolved: &value}).IsSendingOnResolved() { + if value := false; (&Alert{SendOnResolved: &value}).IsSendingOnResolved() { t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false") } - if value := true; !(Alert{SendOnResolved: &value}).IsSendingOnResolved() { + if value := true; !(&Alert{SendOnResolved: &value}).IsSendingOnResolved() { t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true") } } From a90b8efe950a3ba457fb133f10a6411951839c24 Mon Sep 17 00:00:00 2001 From: TwiN Date: Sun, 7 Apr 2024 12:18:00 -0400 Subject: [PATCH 5/9] Validate external endpoints on start --- config/config.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/config/config.go b/config/config.go index 824730b55..bc58acd7d 100644 --- a/config/config.go +++ b/config/config.go @@ -255,6 +255,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) { if err := validateEndpointsConfig(config); err != nil { return nil, err } + if err := validateExternalEndpointsConfig(config); err != nil { + return nil, err + } if err := validateWebConfig(config); err != nil { return nil, err } @@ -350,6 +353,19 @@ func validateEndpointsConfig(config *Config) error { return nil } +func validateExternalEndpointsConfig(config *Config) error { + for _, externalEndpoint := range config.ExternalEndpoints { + if config.Debug { + log.Printf("[config.validateExternalEndpointsConfig] Validating external endpoint '%s'", externalEndpoint.Name) + } + if err := externalEndpoint.ValidateAndSetDefaults(); err != nil { + return fmt.Errorf("invalid external endpoint %s: %w", externalEndpoint.DisplayName(), err) + } + } + log.Printf("[config.validateExternalEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints)) + return nil +} + func validateSecurityConfig(config *Config) error { if config.Security != nil { if config.Security.IsValid() { From e34e61502f91742c77eb268985c9959d203a107f Mon Sep 17 00:00:00 2001 From: TwiN Date: Sun, 7 Apr 2024 17:57:25 -0400 Subject: [PATCH 6/9] Add tests for external endpoints --- api/external_endpoint.go | 8 ++- api/external_endpoint_test.go | 131 ++++++++++++++++++++++++++++++++++ config/config_test.go | 38 +++++++++- 3 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 api/external_endpoint_test.go diff --git a/api/external_endpoint.go b/api/external_endpoint.go index 0e0acbeac..93ec5809d 100644 --- a/api/external_endpoint.go +++ b/api/external_endpoint.go @@ -30,10 +30,16 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler { if len(token) == 0 { return c.Status(401).SendString("bearer token must not be empty") } - externalEndpoint := cfg.GetExternalEndpointByKey(c.Params("key")) + key := c.Params("key") + externalEndpoint := cfg.GetExternalEndpointByKey(key) if externalEndpoint == nil { + log.Printf("[api.CreateExternalEndpointResult] External endpoint with key=%s not found", key) return c.Status(404).SendString("not found") } + if externalEndpoint.Token != token { + log.Printf("[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s", key) + return c.Status(401).SendString("invalid token") + } // Persist the result in the storage result := &core.Result{ Timestamp: time.Now(), diff --git a/api/external_endpoint_test.go b/api/external_endpoint_test.go new file mode 100644 index 000000000..72078cb70 --- /dev/null +++ b/api/external_endpoint_test.go @@ -0,0 +1,131 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/TwiN/gatus/v5/alerting" + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/alerting/provider/discord" + "github.com/TwiN/gatus/v5/config" + "github.com/TwiN/gatus/v5/config/maintenance" + "github.com/TwiN/gatus/v5/core" + "github.com/TwiN/gatus/v5/storage/store" + "github.com/TwiN/gatus/v5/storage/store/common/paging" +) + +func TestCreateExternalEndpointResult(t *testing.T) { + defer store.Get().Clear() + defer cache.Clear() + cfg := &config.Config{ + Alerting: &alerting.Config{ + Discord: &discord.AlertProvider{}, + }, + ExternalEndpoints: []*core.ExternalEndpoint{ + { + Name: "n", + Group: "g", + Token: "token", + Alerts: []*alert.Alert{ + { + Type: alert.TypeDiscord, + FailureThreshold: 2, + SuccessThreshold: 2, + }, + }, + }, + }, + Maintenance: &maintenance.Config{}, + } + api := New(cfg) + router := api.Router() + scenarios := []struct { + Name string + Path string + AuthorizationHeaderBearerToken string + ExpectedCode int + }{ + { + Name: "no-token", + Path: "/api/v1/endpoints/g_n/external?success=true", + AuthorizationHeaderBearerToken: "", + ExpectedCode: 401, + }, + { + Name: "bad-token", + Path: "/api/v1/endpoints/g_n/external?success=true", + AuthorizationHeaderBearerToken: "Bearer bad-token", + ExpectedCode: 401, + }, + { + Name: "bad-key", + Path: "/api/v1/endpoints/bad_key/external?success=true", + AuthorizationHeaderBearerToken: "Bearer token", + ExpectedCode: 404, + }, + { + Name: "good-token-success-true", + Path: "/api/v1/endpoints/g_n/external?success=true", + AuthorizationHeaderBearerToken: "Bearer token", + ExpectedCode: 200, + }, + { + Name: "good-token-success-false", + Path: "/api/v1/endpoints/g_n/external?success=false", + AuthorizationHeaderBearerToken: "Bearer token", + ExpectedCode: 200, + }, + { + Name: "good-token-success-false-again", + Path: "/api/v1/endpoints/g_n/external?success=false", + AuthorizationHeaderBearerToken: "Bearer token", + ExpectedCode: 200, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + request := httptest.NewRequest("POST", scenario.Path, http.NoBody) + if len(scenario.AuthorizationHeaderBearerToken) > 0 { + request.Header.Set("Authorization", scenario.AuthorizationHeaderBearerToken) + } + response, err := router.Test(request) + if err != nil { + return + } + defer response.Body.Close() + if response.StatusCode != scenario.ExpectedCode { + t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode) + } + }) + } + t.Run("verify-end-results", func(t *testing.T) { + endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 10)) + if err != nil { + t.Errorf("failed to get endpoint status: %s", err.Error()) + return + } + if endpointStatus.Key != "g_n" { + t.Errorf("expected key to be g_n but got %s", endpointStatus.Key) + } + if len(endpointStatus.Results) != 3 { + t.Errorf("expected 3 results but got %d", len(endpointStatus.Results)) + } + if !endpointStatus.Results[0].Success { + t.Errorf("expected first result to be successful") + } + if endpointStatus.Results[1].Success { + t.Errorf("expected second result to be unsuccessful") + } + if endpointStatus.Results[2].Success { + t.Errorf("expected third result to be unsuccessful") + } + externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n") + if externalEndpointFromConfig.NumberOfFailuresInARow != 2 { + t.Errorf("expected 2 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow) + } + if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 { + t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow) + } + }) +} diff --git a/config/config_test.go b/config/config_test.go index e56fb8d45..2f0769364 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -304,11 +304,13 @@ func TestParseAndValidateConfigBytes(t *testing.T) { storage: type: sqlite path: %s + maintenance: enabled: true start: 00:00 duration: 4h every: [Monday, Thursday] + ui: title: T header: H @@ -318,6 +320,16 @@ ui: link: "https://example.org" - name: "Status page" link: "https://status.example.org" + +external-endpoints: + - name: ext-ep-test + group: core + token: "potato" + alerts: + - type: discord + description: "healthcheck failed" + send-on-resolved: true + endpoints: - name: website url: https://twin.sh/health @@ -358,10 +370,33 @@ endpoints: if mc := config.Maintenance; mc == nil || mc.Start != "00:00" || !mc.IsEnabled() || mc.Duration != 4*time.Hour || len(mc.Every) != 2 { t.Error("Expected Config.Maintenance to be configured properly") } + if len(config.ExternalEndpoints) != 1 { + t.Error("Should have returned one external endpoint") + } + if config.ExternalEndpoints[0].Name != "ext-ep-test" { + t.Errorf("Name should have been %s", "ext-ep-test") + } + if config.ExternalEndpoints[0].Group != "core" { + t.Errorf("Group should have been %s", "core") + } + if config.ExternalEndpoints[0].Token != "potato" { + t.Errorf("Token should have been %s", "potato") + } + if len(config.ExternalEndpoints[0].Alerts) != 1 { + t.Error("Should have returned one alert") + } + if config.ExternalEndpoints[0].Alerts[0].Type != alert.TypeDiscord { + t.Errorf("Type should have been %s", alert.TypeDiscord) + } + if config.ExternalEndpoints[0].Alerts[0].FailureThreshold != 3 { + t.Errorf("FailureThreshold should have been %d, got %d", 3, config.ExternalEndpoints[0].Alerts[0].FailureThreshold) + } + if config.ExternalEndpoints[0].Alerts[0].SuccessThreshold != 2 { + t.Errorf("SuccessThreshold should have been %d, got %d", 2, config.ExternalEndpoints[0].Alerts[0].SuccessThreshold) + } if len(config.Endpoints) != 3 { t.Error("Should have returned two endpoints") } - if config.Endpoints[0].URL != "https://twin.sh/health" { t.Errorf("URL should have been %s", "https://twin.sh/health") } @@ -383,7 +418,6 @@ endpoints: if len(config.Endpoints[0].Conditions) != 1 { t.Errorf("There should have been %d conditions", 1) } - if config.Endpoints[1].URL != "https://api.github.com/healthz" { t.Errorf("URL should have been %s", "https://api.github.com/healthz") } From e7d8e17a4987cb133c0bb1619a930cd86c4d491e Mon Sep 17 00:00:00 2001 From: TwiN Date: Sun, 7 Apr 2024 17:59:25 -0400 Subject: [PATCH 7/9] refactor some error equality checks --- storage/store/sql/sql.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/storage/store/sql/sql.go b/storage/store/sql/sql.go index 5b63cc5a0..cd94c8246 100644 --- a/storage/store/sql/sql.go +++ b/storage/store/sql/sql.go @@ -556,7 +556,7 @@ func (s *Store) getEndpointIDGroupAndNameByKey(tx *sql.Tx, key string) (id int64 key, ).Scan(&id, &group, &name) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return 0, "", "", common.ErrEndpointNotFound } return 0, "", "", err @@ -738,7 +738,7 @@ func (s *Store) getEndpointID(tx *sql.Tx, endpoint *core.Endpoint) (int64, error var id int64 err := tx.QueryRow("SELECT endpoint_id FROM endpoints WHERE endpoint_key = $1", endpoint.Key()).Scan(&id) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return 0, common.ErrEndpointNotFound } return 0, err @@ -788,7 +788,7 @@ func (s *Store) getLastEndpointResultSuccessValue(tx *sql.Tx, endpointID int64) var success bool err := tx.QueryRow("SELECT success FROM endpoint_results WHERE endpoint_id = $1 ORDER BY endpoint_result_id DESC LIMIT 1", endpointID).Scan(&success) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return false, errNoRowsReturned } return false, err From e367f39d4ee812078b53b3449750d9229fecdfbf Mon Sep 17 00:00:00 2001 From: TwiN Date: Mon, 8 Apr 2024 19:28:19 -0400 Subject: [PATCH 8/9] Improve docs and refactor some code --- README.md | 27 ++++++++++++++++++--------- config/config.go | 2 -- core/external_endpoint_test.go | 25 +++++++++++++++++++++++++ core/ssh.go | 2 +- 4 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 core/external_endpoint_test.go diff --git a/README.md b/README.md index 245e4348e..23abfa806 100644 --- a/README.md +++ b/README.md @@ -216,9 +216,9 @@ If you want to test it locally, see [Docker](#docker). | `debug` | Whether to enable debug logs. | `false` | | `metrics` | Whether to expose metrics at `/metrics`. | `false` | | `storage` | [Storage configuration](#storage). | `{}` | +| `alerting` | [Alerting configuration](#alerting). | `{}` | | `endpoints` | [Endpoints configuration](#endpoints). | Required `[]` | | `external-endpoints` | [External Endpoints configuration](#external-endpoints). | `[]` | -| `alerting` | [Alerting configuration](#alerting). | `{}` | | `security` | [Security configuration](#security). | `{}` | | `disable-monitoring-lock` | Whether to [disable the monitoring lock](#disable-monitoring-lock). | `false` | | `skip-invalid-config-update` | Whether to ignore invalid configuration update.
See [Reloading configuration on the fly](#reloading-configuration-on-the-fly). | `false` | @@ -303,6 +303,15 @@ external-endpoints: send-on-resolved: true ``` +To push the status of an external endpoint, the request would have to look like this: +``` +POST /api/v1/endpoints/{key}/external?success={success} +``` +Where: +- `{key}` has the pattern `_` in which both variables have ` `, `/`, `_`, `,` and `.` replaced by `-`. + - Using the example configuration above, the key would be `core_ext-ep-test`. +- `{success}` is a boolean (`true` or `false`) value indicating whether the health check was successful or not. + ### Conditions Here are some examples of conditions you can use: @@ -411,7 +420,7 @@ the client used to send the request. | `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` | > 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved -in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything. +> in ICMP requests (ping), therefore, setting `client.insecure` to `true` for an endpoint of that type will not do anything. This default configuration is as follows: @@ -512,7 +521,7 @@ endpoints: ``` > 📝 If an alerting provider is not properly configured, all alerts configured with the provider's type will be -ignored. +> ignored. | Parameter | Description | Default | |:--------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:--------| @@ -1473,7 +1482,7 @@ security: ``` > ⚠ Make sure to carefully select to cost of the bcrypt hash. The higher the cost, the longer it takes to compute the hash, -and basic auth verifies the password against the hash on every request. As of 2023-01-06, I suggest a cost of 9. +> and basic auth verifies the password against the hash on every request. As of 2023-01-06, I suggest a cost of 9. #### OIDC @@ -1723,8 +1732,8 @@ Placeholders `[STATUS]` and `[BODY]` as well as the fields `endpoints[].body`, ` This works for applications such as databases (Postgres, MySQL, etc.) and caches (Redis, Memcached, etc.). > 📝 `[CONNECTED] == true` does not guarantee that the endpoint itself is healthy - it only guarantees that there's -something at the given address listening to the given port, and that a connection to that address was successfully -established. +> something at the given address listening to the given port, and that a connection to that address was successfully +> established. ### Monitoring a UDP endpoint @@ -1886,9 +1895,9 @@ endpoints: ``` > ⚠ The usage of the `[DOMAIN_EXPIRATION]` placeholder requires Gatus to send a request to the official IANA WHOIS service [through a library](https://github.com/TwiN/whois) -and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`). -To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from -using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`. +> and in some cases, a secondary request to a TLD-specific WHOIS server (e.g. `whois.nic.sh`). +> To prevent the WHOIS service from throttling your IP address if you send too many requests, Gatus will prevent you from +> using the `[DOMAIN_EXPIRATION]` placeholder on an endpoint with an interval of less than `5m`. ### disable-monitoring-lock diff --git a/config/config.go b/config/config.go index bc58acd7d..667be593e 100644 --- a/config/config.go +++ b/config/config.go @@ -103,7 +103,6 @@ type Config struct { } func (config *Config) GetEndpointByKey(key string) *core.Endpoint { - // TODO: Should probably add a mutex here to prevent concurrent access for i := 0; i < len(config.Endpoints); i++ { ep := config.Endpoints[i] if util.ConvertGroupAndEndpointNameToKey(ep.Group, ep.Name) == key { @@ -114,7 +113,6 @@ func (config *Config) GetEndpointByKey(key string) *core.Endpoint { } func (config *Config) GetExternalEndpointByKey(key string) *core.ExternalEndpoint { - // TODO: Should probably add a mutex here to prevent concurrent access for i := 0; i < len(config.ExternalEndpoints); i++ { ee := config.ExternalEndpoints[i] if util.ConvertGroupAndEndpointNameToKey(ee.Group, ee.Name) == key { diff --git a/core/external_endpoint_test.go b/core/external_endpoint_test.go new file mode 100644 index 000000000..a79456c3f --- /dev/null +++ b/core/external_endpoint_test.go @@ -0,0 +1,25 @@ +package core + +import ( + "testing" +) + +func TestExternalEndpoint_ToEndpoint(t *testing.T) { + externalEndpoint := &ExternalEndpoint{ + Name: "name", + Group: "group", + } + convertedEndpoint := externalEndpoint.ToEndpoint() + if externalEndpoint.Name != convertedEndpoint.Name { + t.Errorf("expected %s, got %s", externalEndpoint.Name, convertedEndpoint.Name) + } + if externalEndpoint.Group != convertedEndpoint.Group { + t.Errorf("expected %s, got %s", externalEndpoint.Group, convertedEndpoint.Group) + } + if externalEndpoint.Key() != convertedEndpoint.Key() { + t.Errorf("expected %s, got %s", externalEndpoint.Key(), convertedEndpoint.Key()) + } + if externalEndpoint.DisplayName() != convertedEndpoint.DisplayName() { + t.Errorf("expected %s, got %s", externalEndpoint.DisplayName(), convertedEndpoint.DisplayName()) + } +} diff --git a/core/ssh.go b/core/ssh.go index 80720e8a2..b0349bac7 100644 --- a/core/ssh.go +++ b/core/ssh.go @@ -17,7 +17,7 @@ type SSH struct { Password string `yaml:"password,omitempty"` } -// validateAndSetDefaults validates the endpoint +// validate validates the endpoint func (s *SSH) validate() error { if len(s.Username) == 0 { return ErrEndpointWithoutSSHUsername From f93e770a685488f9cb13a7c51b2e1b4b97eb274d Mon Sep 17 00:00:00 2001 From: TwiN Date: Mon, 8 Apr 2024 20:46:33 -0400 Subject: [PATCH 9/9] Fix UI-related issues with external endpoints --- api/badge.go | 8 ++++++-- api/endpoint_status.go | 4 ++-- core/result.go | 4 ++-- web/app/src/components/Tooltip.vue | 14 ++++++++------ web/app/src/views/Details.vue | 16 ++++++++++++---- web/static/css/app.css | 2 +- web/static/js/app.js | 2 +- 7 files changed, 32 insertions(+), 18 deletions(-) diff --git a/api/badge.go b/api/badge.go index 287da3947..fb04fce37 100644 --- a/api/badge.go +++ b/api/badge.go @@ -9,6 +9,7 @@ import ( "time" "github.com/TwiN/gatus/v5/config" + "github.com/TwiN/gatus/v5/core/ui" "github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common/paging" @@ -272,10 +273,13 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key } func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string { - endpoint := cfg.GetEndpointByKey(key) + thresholds := ui.GetDefaultConfig().Badge.ResponseTime.Thresholds + if endpoint := cfg.GetEndpointByKey(key); endpoint != nil { + thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds + } // the threshold config requires 5 values, so we can be sure it's set here for i := 0; i < 5; i++ { - if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] { + if responseTime <= thresholds[i] { return badgeColors[i] } } diff --git a/api/endpoint_status.go b/api/endpoint_status.go index 13b7e8bad..ac9e37b5e 100644 --- a/api/endpoint_status.go +++ b/api/endpoint_status.go @@ -65,13 +65,13 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor body, err := io.ReadAll(response.Body) if err != nil { _ = response.Body.Close() - log.Printf("[handler.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error()) + log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error()) continue } var endpointStatuses []*core.EndpointStatus if err = json.Unmarshal(body, &endpointStatuses); err != nil { _ = response.Body.Close() - log.Printf("[handler.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error()) + log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error()) continue } _ = response.Body.Close() diff --git a/core/result.go b/core/result.go index ddbfad2e8..884493d50 100644 --- a/core/result.go +++ b/core/result.go @@ -7,7 +7,7 @@ import ( // Result of the evaluation of a Endpoint type Result struct { // HTTPStatus is the HTTP response status code - HTTPStatus int `json:"status"` + HTTPStatus int `json:"status,omitempty"` // DNSRCode is the response code of a DNS query in a human-readable format // @@ -30,7 +30,7 @@ type Result struct { Errors []string `json:"errors,omitempty"` // ConditionResults results of the Endpoint's conditions - ConditionResults []*ConditionResult `json:"conditionResults"` + ConditionResults []*ConditionResult `json:"conditionResults,omitempty"` // Success whether the result signifies a success or not Success bool `json:"success"` diff --git a/web/app/src/components/Tooltip.vue b/web/app/src/components/Tooltip.vue index 5755af653..c5964df16 100644 --- a/web/app/src/components/Tooltip.vue +++ b/web/app/src/components/Tooltip.vue @@ -5,12 +5,14 @@ {{ prettifyTimestamp(result.timestamp) }}
Response time:
{{ (result.duration / 1000000).toFixed(0) }}ms -
Conditions:
- - - {{ conditionResult.success ? "✓" : "X" }} ~ {{ conditionResult.condition }}
-
-
+ +
Conditions:
+ + + {{ conditionResult.success ? "✓" : "X" }} ~ {{ conditionResult.condition }}
+
+
+
Errors:
diff --git a/web/app/src/views/Details.vue b/web/app/src/views/Details.vue index a0c7e4dc3..18706475d 100644 --- a/web/app/src/views/Details.vue +++ b/web/app/src/views/Details.vue @@ -1,6 +1,6 @@