diff --git a/.github/assets/gotify-alerts.png b/.github/assets/gotify-alerts.png new file mode 100644 index 00000000..a36387b8 Binary files /dev/null and b/.github/assets/gotify-alerts.png differ diff --git a/README.md b/README.md index 231183d0..aba5fb99 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Configuring GitHub alerts](#configuring-github-alerts) - [Configuring GitLab alerts](#configuring-gitlab-alerts) - [Configuring Google Chat alerts](#configuring-google-chat-alerts) + - [Configuring Gotify alerts](#configuring-gotify-alerts) - [Configuring Matrix alerts](#configuring-matrix-alerts) - [Configuring Mattermost alerts](#configuring-mattermost-alerts) - [Configuring Messagebird alerts](#configuring-messagebird-alerts) @@ -423,6 +424,7 @@ ignored. | `alerting.github` | Configuration for alerts of type `github`.
See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` | | `alerting.gitlab` | Configuration for alerts of type `gitlab`.
See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` | | `alerting.googlechat` | Configuration for alerts of type `googlechat`.
See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` | +| `alerting.gotify` | Configuration for alerts of type `gotify`.
See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` | | `alerting.matrix` | Configuration for alerts of type `matrix`.
See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` | | `alerting.mattermost` | Configuration for alerts of type `mattermost`.
See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` | | `alerting.messagebird` | Configuration for alerts of type `messagebird`.
See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` | @@ -638,6 +640,41 @@ endpoints: ``` +#### Configuring Gotify alerts +| Parameter | Description | Default | +|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:-----------------------| +| `alerting.gotify` | Configuration for alerts of type `gotify` | `{}` | +| `alerting.gotify.server-url` | Gotify server URL | Required `""` | +| `alerting.gotify.token` | Token that is used for authentication. | Required `""` | +| `alerting.gotify.priority` | Priority of the alert according to Gotify standarts. | `5` | +| `alerting.gotify.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert). | N/A | +| `alerting.gotify.title` | Title of the notification | `"Gatus: "` | + +```yaml +alerting: + gotify: + server-url: "https://gotify.example" + token: "**************" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 5m + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 300" + alerts: + - type: gotify + description: "healthcheck failed" + send-on-resolved: true +``` + +Here's an example of what the notifications look like: + +![Gotify notifications](.github/assets/gotify-alerts.png) + + #### Configuring Matrix alerts | Parameter | Description | Default | |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------| diff --git a/alerting/alert/type.go b/alerting/alert/type.go index 72e201bc..22f7b8d6 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -26,6 +26,9 @@ const ( // TypeGoogleChat is the Type for the googlechat alerting provider TypeGoogleChat Type = "googlechat" + // TypeGotify is the Type for the gotify alerting provider + TypeGotify Type = "gotify" + // TypeMatrix is the Type for the matrix alerting provider TypeMatrix Type = "matrix" diff --git a/alerting/config.go b/alerting/config.go index 6b35208d..af52115d 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -14,6 +14,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/googlechat" + "github.com/TwiN/gatus/v5/alerting/provider/gotify" "github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/messagebird" @@ -50,6 +51,9 @@ type Config struct { // GoogleChat is the configuration for the googlechat alerting provider GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"` + // Gotify is the configuration for the gotify alerting provider + Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"` + // Matrix is the configuration for the matrix alerting provider Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"` diff --git a/alerting/provider/gotify/gotify.go b/alerting/provider/gotify/gotify.go new file mode 100644 index 00000000..e9b1d59b --- /dev/null +++ b/alerting/provider/gotify/gotify.go @@ -0,0 +1,105 @@ +package gotify + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/core" +) + +const DefaultPriority = 5 + +// AlertProvider is the configuration necessary for sending an alert using Gotify +type AlertProvider struct { + // ServerURL is the URL of the Gotify server + ServerURL string `yaml:"server-url"` + + // Token is the token to use when sending a message to the Gotify server + Token string `yaml:"token"` + + // Priority is the priority of the message + Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Title is the title of the message that will be sent + Title string `yaml:"title,omitempty"` +} + +// IsValid returns whether the provider's configuration is valid +func (provider *AlertProvider) IsValid() bool { + if provider.Priority == 0 { + provider.Priority = DefaultPriority + } + return len(provider.ServerURL) > 0 && len(provider.Token) > 0 +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved)) + request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode > 399 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("failed to send alert to Gotify: %s", string(body)) + } + return nil +} + +type Body struct { + Message string `json:"message"` + Title string `json:"title"` + Priority int `json:"priority"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte { + var message, results string + if resolved { + message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold) + } else { + message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold) + } + for _, conditionResult := range result.ConditionResults { + var prefix string + if conditionResult.Success { + prefix = "✓" + } else { + prefix = "✕" + } + results += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition) + } + if len(alert.GetDescription()) > 0 { + message += " with the following description: " + alert.GetDescription() + } + message += results + title := "Gatus: " + endpoint.DisplayName() + if provider.Title != "" { + title = provider.Title + } + body, _ := json.Marshal(Body{ + Message: message, + Title: title, + Priority: provider.Priority, + }) + return body +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} diff --git a/alerting/provider/gotify/gotify_test.go b/alerting/provider/gotify/gotify_test.go new file mode 100644 index 00000000..19a68b97 --- /dev/null +++ b/alerting/provider/gotify/gotify_test.go @@ -0,0 +1,105 @@ +package gotify + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/core" +) + +func TestAlertProvider_IsValid(t *testing.T) { + scenarios := []struct { + name string + provider AlertProvider + expected bool + }{ + { + name: "valid", + provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, + expected: true, + }, + { + name: "invalid-server-url", + provider: AlertProvider{ServerURL: "", Token: "faketoken"}, + expected: false, + }, + { + name: "invalid-app-token", + provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""}, + expected: false, + }, + { + name: "no-priority-should-use-default-value", + provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, + expected: true, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + if scenario.provider.IsValid() != scenario.expected { + t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid()) + } + }) + } +} + +func TestAlertProvider_buildRequestBody(t *testing.T) { + var ( + description = "custom-description" + //title = "custom-title" + endpoint = "custom-endpoint" + ) + scenarios := []struct { + Name string + Provider AlertProvider + Alert alert.Alert + Resolved bool + ExpectedBody string + }{ + { + Name: "triggered", + Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, + Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description), + }, + { + Name: "resolved", + Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, + Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description), + }, + { + Name: "custom-title", + Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}, + Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpoint, description), + }, + } + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + body := scenario.Provider.buildRequestBody( + &core.Endpoint{Name: endpoint}, + &scenario.Alert, + &core.Result{ + ConditionResults: []*core.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: scenario.Resolved}, + {Condition: "[STATUS] == 200", Success: scenario.Resolved}, + }, + }, + scenario.Resolved, + ) + if string(body) != scenario.ExpectedBody { + t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) + } + out := make(map[string]interface{}) + if err := json.Unmarshal(body, &out); err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } + }) + } +} diff --git a/config/config.go b/config/config.go index d2824ca9..70f62f2c 100644 --- a/config/config.go +++ b/config/config.go @@ -367,6 +367,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E alert.TypeGitHub, alert.TypeGitLab, alert.TypeGoogleChat, + alert.TypeGotify, alert.TypeEmail, alert.TypeMatrix, alert.TypeMattermost,