diff --git a/README.md b/README.md index e5bb2ed16..510881850 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Configuring Teams alerts *(Deprecated)*](#configuring-teams-alerts-deprecated) - [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts) - [Configuring Telegram alerts](#configuring-telegram-alerts) + - [Configuring Threema Gateway alerts](#configuring-threema-gateway-alerts) - [Configuring Twilio alerts](#configuring-twilio-alerts) - [Configuring Vonage alerts](#configuring-vonage-alerts) - [Configuring Webex alerts](#configuring-webex-alerts) @@ -867,6 +868,7 @@ endpoints: | `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)*
See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` | | `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`.
See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram`.
See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` | +| `alerting.threema-gateway` | Configuration for alerts of type `threema-gateway`.
See [Configuring Threema Gateway alerts](#configuring-threema-gateway-alerts).| `{}` | | `alerting.twilio` | Settings for alerts of type `twilio`.
See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` | | `alerting.vonage` | Configuration for alerts of type `vonage`.
See [Configuring Vonage alerts](#configuring-vonage-alerts). | `{}` | | `alerting.webex` | Configuration for alerts of type `webex`.
See [Configuring Webex alerts](#configuring-webex-alerts). | `{}` | @@ -2314,6 +2316,37 @@ Here's an example of what the notifications look like: ![Telegram notifications](.github/assets/telegram-alerts.png) +#### Configuring Threema Gateway alerts +| Parameter | Description | Default | +|:---------------------------------------------|:------------------------------------------------------------------------------------------------------------|:----------------------------| +| `alerting.threema-gateway` | Configuration for alerts of type `threema-gateway` | `{}` | +| `alerting.threema-gateway.api-base-url` | Threema Gateway API Base URL | `https://msgapi.threema.ch` | +| `alerting.threema-gateway.api-identity` | Personal Threema API Identity | Required `""` | +| `alerting.threema-gateway.recipients` | Recipients in format `[:]`, where type is `id`, `phone` or `email`. Type defaults to `id` | Required `[]` | +| `alerting.threema-gateway.auth-secret` | Threema Gateway API authentication secret | Required `""` | +| `alerting.threema-gateway.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.threema-gateway.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.threema-gateway.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.threema-gateway.overrides[].*` | See `alerting.threema-gateway.*` parameters | `{}` | + +```yaml +alerting: + threema-gateway: + api-identity: "*ABCDEFG" + auth-secret: "secret" + recipients: + - "THREEMA_ID" + +endpoints: + - name: website + url: "https://twin.sh/health" + conditions: + - "[STATUS] == 200" + alerts: + - type: threema-gateway +``` + + #### Configuring Twilio alerts | Parameter | Description | Default | |:--------------------------------|:-------------------------------------------------------------------------------------------|:--------------| diff --git a/alerting/alert/type.go b/alerting/alert/type.go index 6a5759779..c55d01cbd 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -113,6 +113,9 @@ const ( // TypeTelegram is the Type for the telegram alerting provider TypeTelegram Type = "telegram" + // TypeThreemaGateway is the Type for the threema-gateway alerting provider + TypeThreemaGateway Type = "threema-gateway" + // TypeTwilio is the Type for the twilio alerting provider TypeTwilio Type = "twilio" diff --git a/alerting/config.go b/alerting/config.go index 923d6cbd8..2c023cb4a 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -42,6 +42,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows" "github.com/TwiN/gatus/v5/alerting/provider/telegram" + "github.com/TwiN/gatus/v5/alerting/provider/threemagateway" "github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/vonage" "github.com/TwiN/gatus/v5/alerting/provider/webex" @@ -160,6 +161,9 @@ type Config struct { // Telegram is the configuration for the telegram alerting provider Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"` + // ThreemaGateway is the configuration for the threema-gatway alerting provider + ThreemaGateway *threemagateway.AlertProvider `yaml:"threema-gateway,omitempty"` + // Twilio is the configuration for the twilio alerting provider Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"` diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index 35beb92c3..4db4880ec 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -38,6 +38,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows" "github.com/TwiN/gatus/v5/alerting/provider/telegram" + "github.com/TwiN/gatus/v5/alerting/provider/threemagateway" "github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/webex" "github.com/TwiN/gatus/v5/alerting/provider/zapier" @@ -128,6 +129,7 @@ var ( _ AlertProvider = (*teams.AlertProvider)(nil) _ AlertProvider = (*teamsworkflows.AlertProvider)(nil) _ AlertProvider = (*telegram.AlertProvider)(nil) + _ AlertProvider = (*threemagateway.AlertProvider)(nil) _ AlertProvider = (*twilio.AlertProvider)(nil) _ AlertProvider = (*webex.AlertProvider)(nil) _ AlertProvider = (*zapier.AlertProvider)(nil) @@ -170,6 +172,7 @@ var ( _ Config[teams.Config] = (*teams.Config)(nil) _ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil) _ Config[telegram.Config] = (*telegram.Config)(nil) + _ Config[threemagateway.Config] = (*threemagateway.Config)(nil) _ Config[twilio.Config] = (*twilio.Config)(nil) _ Config[webex.Config] = (*webex.Config)(nil) _ Config[zapier.Config] = (*zapier.Config)(nil) diff --git a/alerting/provider/threemagateway/threemagateway.go b/alerting/provider/threemagateway/threemagateway.go new file mode 100644 index 000000000..81be48dd3 --- /dev/null +++ b/alerting/provider/threemagateway/threemagateway.go @@ -0,0 +1,387 @@ +package threemagateway + +import ( + "encoding" + "errors" + "fmt" + "maps" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" + "gopkg.in/yaml.v3" +) + +const ( + defaultAPIBaseURL = "https://msgapi.threema.ch" + + defaultRecipientType = RecipientTypeID +) + +var ( + errAPIIdentityMissing = errors.New("api-identity is required") + errAPIAuthSecretMissing = errors.New("auth-secret is required") + errRecipientsMissing = errors.New("at least one recipient is required") + + errRecipientsTooMany = errors.New("too many recipients for the selected mode") + errInvalidRecipientTypeForE2EE = errors.New("only recipient type 'id' is supported in e2ee modes") + + errE2EENotImplemented = errors.New("e2ee mode is not implemented yet") + + errInvalidRecipientFormat = errors.New("recipient must be in the format '[:]'") + errInvalidRecipientType = fmt.Errorf("invalid recipient type, must be one of: %v", joinKeys(validRecipientTypes, ", ")) + validRecipientTypes = map[string]RecipientType{ + "id": RecipientTypeID, + "phone": RecipientTypePhone, + "email": RecipientTypeEmail, + } + + errInvalidThreemaID = errors.New("invalid id: must be 8 characters long and alphabetic characters must be uppercase") + errInvalidPhoneNumberFormat = errors.New("invalid phone number: must contain only digits and may start with '+'") + errInvalidEmailAddressFormat = errors.New("invalid email address: must contain '@'") +) + +func joinKeys[V any](m map[string]V, separator string) string { + return strings.Join(slices.Collect(maps.Keys(m)), separator) +} + +type SendMode int + +const ( + SendModeBasic SendMode = iota + SendModeE2EE + SendModeE2EEBulk +) + +type RecipientType int + +const ( + RecipientTypeInvalid RecipientType = iota + RecipientTypeID + RecipientTypePhone + RecipientTypeEmail +) + +func parseRecipientType(s string) RecipientType { + if val, ok := validRecipientTypes[s]; ok { + return val + } + return RecipientTypeInvalid +} + +type Recipient struct { + Value string `yaml:"-"` + Type RecipientType `yaml:"-"` +} + +var _ encoding.TextUnmarshaler = (*Recipient)(nil) +var _ encoding.TextMarshaler = (*Recipient)(nil) + +func (r *Recipient) UnmarshalText(text []byte) error { + parts := strings.Split(string(text), ":") + switch { + case len(parts) > 2: + return errInvalidRecipientFormat + case len(parts) == 2: + if r.Type = parseRecipientType(parts[0]); r.Type == RecipientTypeInvalid { + return errInvalidRecipientType + } + r.Value = parts[1] + default: + r.Type = defaultRecipientType + r.Value = parts[0] + } + return nil +} + +func (r Recipient) MarshalText() ([]byte, error) { + if r.Type == RecipientTypeInvalid { + return []byte("invalid" + ":" + r.Value), nil + } + for key, val := range validRecipientTypes { + if val == r.Type { + return []byte(key + ":" + r.Value), nil + } + } + return nil, errInvalidRecipientType +} + +func (r *Recipient) Validate() error { + if len(r.Value) == 0 { + return errInvalidRecipientFormat + } + switch r.Type { + case RecipientTypeID: + if err := validateThreemaId(r.Value); err != nil { + return err + } + case RecipientTypePhone: + r.Value = strings.TrimPrefix(r.Value, "+") + if !isValidPhoneNumber(r.Value) { + return errInvalidPhoneNumberFormat + } + case RecipientTypeEmail: + if !strings.Contains(r.Value, "@") { + return errInvalidEmailAddressFormat + } + default: + return errInvalidRecipientType + } + return nil +} + +func validateThreemaId(id string) error { + if len(id) != 8 || strings.ToUpper(id) != id { + return errInvalidThreemaID + } + return nil +} + +func isValidPhoneNumber(number string) bool { + if len(number) == 0 { + return false + } + for _, ch := range number { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +type Config struct { + APIBaseURL string `yaml:"api-base-url"` + APIIdentity string `yaml:"api-identity"` + Recipients []Recipient `yaml:"recipients"` // TODO#1470: Remove comment: This is a list to support bulk sending in e2ee-bulk mode once implemented + APIAuthSecret string `yaml:"auth-secret"` + PrivateKey string `yaml:"-,omitempty"` // TODO#1470: Enable in yaml once e2ee modes are implemented + + Mode SendMode `yaml:"-"` +} + +func (cfg *Config) Validate() error { + // Determine and validate mode + switch { + case len(cfg.PrivateKey) > 0 && len(cfg.Recipients) <= 1: + cfg.Mode = SendModeE2EE + return errE2EENotImplemented + case len(cfg.PrivateKey) > 0 && len(cfg.Recipients) > 1: + cfg.Mode = SendModeE2EEBulk + return errE2EENotImplemented + default: + cfg.Mode = SendModeBasic + } + + // Validate API Base URL + if len(cfg.APIBaseURL) == 0 { + cfg.APIBaseURL = defaultAPIBaseURL + } + + // Validate API Identity + if len(cfg.APIIdentity) == 0 { + return errAPIIdentityMissing + } + if err := validateThreemaId(cfg.APIIdentity); err != nil { + return fmt.Errorf("api-identity: %w", err) + } + + // Validate Recipients + if len(cfg.Recipients) == 0 { + return errRecipientsMissing + } + if cfg.Mode != SendModeE2EEBulk && len(cfg.Recipients) > 1 { + return errRecipientsTooMany + } + for _, recipient := range cfg.Recipients { + if err := recipient.Validate(); err != nil { + return fmt.Errorf("recipients: %s: %w", recipient.Value, err) + } + } + + // Validate API Key + if len(cfg.APIAuthSecret) == 0 { + return errAPIAuthSecretMissing + } + return nil +} + +func (cfg *Config) Merge(override *Config) { + if len(override.APIBaseURL) > 0 { + cfg.APIBaseURL = override.APIBaseURL + } + if len(override.APIIdentity) > 0 { + cfg.APIIdentity = override.APIIdentity + } + if len(override.Recipients) > 0 { + cfg.Recipients = override.Recipients + } + if len(override.APIAuthSecret) > 0 { + cfg.APIAuthSecret = override.APIAuthSecret + } +} + +type AlertProvider struct { + DefaultConfig Config `yaml:",inline"` + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + Overrides []Override `yaml:"overrides,omitempty"` +} + +type Override struct { + Group string `yaml:"group"` + Config `yaml:",inline"` +} + +func (provider *AlertProvider) Validate() error { + groupOverrideConfigured := map[string]bool{} + for _, override := range provider.Overrides { + if _, exists := groupOverrideConfigured[override.Group]; exists { + return fmt.Errorf("duplicate override for group: %s", override.Group) + } + groupOverrideConfigured[override.Group] = true + } + return provider.DefaultConfig.Validate() +} + +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + cfg, err := provider.GetConfig(ep.Group, alert) + if err != nil { + return err + } + body := provider.buildMessageBody(ep, alert, result, resolved) + request, err := provider.prepareRequest(cfg, body) + if err != nil { + return err + } + response, err := client.GetHTTPClient(nil).Do(request) + if err != nil { + return err + } + return handleResponse(cfg, response) +} + +func (provider *AlertProvider) buildMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string { + group := ep.Group + if len(group) > 0 { + group += "/" + } + + var body string + if resolved { + body = fmt.Sprintf("✅ *Gatus: %s%s*\nAlert resolved after passing %d checks.", group, ep.Name, alert.SuccessThreshold) + } else { + body = fmt.Sprintf("🚨 *Gatus: %s%s*\nAlert triggered after failing %d checks.\nConditions:", group, ep.Name, alert.FailureThreshold) + for _, conditionResult := range result.ConditionResults { + icon := "❌" + if conditionResult.Success { + icon = "✅" + } + body += fmt.Sprintf("\n %s %s", icon, conditionResult.Condition) + } + if len(result.Errors) > 0 { + body += "\nErrors:" + for _, err := range result.Errors { + body += fmt.Sprintf("\n ❌ %s", err) + } + } + } + return body +} + +func (provider *AlertProvider) prepareRequest(cfg *Config, body string) (*http.Request, error) { + requestURL := cfg.APIBaseURL + switch cfg.Mode { + case SendModeBasic: + requestURL += "/send_simple" + default: + return nil, errE2EENotImplemented + } + + data := url.Values{} + data.Add("from", cfg.APIIdentity) + var toKey string + switch cfg.Recipients[0].Type { + case RecipientTypeID: + toKey = "to" + case RecipientTypePhone: + toKey = "phone" + case RecipientTypeEmail: + toKey = "email" + default: + return nil, errInvalidRecipientType + } + data.Add(toKey, cfg.Recipients[0].Value) + data.Add("text", body) + data.Add("secret", cfg.APIAuthSecret) + + request, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return request, nil +} + +func handleResponse(cfg *Config, response *http.Response) error { + switch response.StatusCode { + case http.StatusOK: + switch cfg.Mode { + case SendModeBasic, SendModeE2EE: + return nil + case SendModeE2EEBulk: + return nil // TODO#1470: Add correct handling once mode is implemented (check success fields in response body) + } + case http.StatusBadRequest: + switch cfg.Mode { + case SendModeBasic, SendModeE2EE: + return fmt.Errorf("%s: Invalid recipient(s) or Threema Account not set up for configured mode", response.Status) + case SendModeE2EEBulk: + // TODO#1470: Add correct error info once mode is implemented + } + case http.StatusUnauthorized: + return fmt.Errorf("%s: Invalid auth-secret or api-identity", response.Status) + case http.StatusPaymentRequired: + return fmt.Errorf("%s: Insufficient credits to send message", response.Status) + case http.StatusNotFound: + if cfg.Mode == SendModeBasic { + return fmt.Errorf("%s: Recipient could not be found", response.Status) + } + } + return fmt.Errorf("Response: %s", response.Status) +} + +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +// GetConfig returns the configuration for the provider with the overrides applied +func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) { + cfg := provider.DefaultConfig + // Handle group overrides + if len(provider.Overrides) > 0 { + for _, override := range provider.Overrides { + if group == override.Group { + cfg.Merge(&override.Config) + break + } + } + } + // Handle alert overrides + if len(alert.ProviderOverride) > 0 { + overrideConfig := Config{} + if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil { + return nil, err + } + cfg.Merge(&overrideConfig) + } + err := cfg.Validate() + return &cfg, err +} + +func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error { + _, err := provider.GetConfig(group, alert) + return err +} diff --git a/alerting/provider/threemagateway/threemagateway_test.go b/alerting/provider/threemagateway/threemagateway_test.go new file mode 100644 index 000000000..836f37302 --- /dev/null +++ b/alerting/provider/threemagateway/threemagateway_test.go @@ -0,0 +1,448 @@ +package threemagateway + +import ( + "errors" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/config/endpoint" +) + +func TestRecipient_UnmarshalText_And_MarshalText(t *testing.T) { + scenarios := []struct { + name string + input string + expected Recipient + expectError error + }{ + { + name: "default recipient type", + input: "individual", + expected: Recipient{Type: defaultRecipientType, Value: "individual"}, + expectError: nil, + }, + { + name: "empty input", + input: "", + expected: Recipient{Type: defaultRecipientType, Value: ""}, + expectError: nil, + }, + { + name: "invalid format", + input: "type:value:extra", + expectError: errInvalidRecipientFormat, + }, + { + name: "invalid recipient type", + input: "unknown:value", + expectError: errInvalidRecipientType, + }, + { + name: "valid phone recipient", + input: "phone:+1234567890", + expected: Recipient{Type: RecipientTypePhone, Value: "+1234567890"}, + expectError: nil, + }, + { + name: "valid email recipient", + input: "email:mail@mail.com", + expected: Recipient{Type: RecipientTypeEmail, Value: "mail@mail.com"}, + expectError: nil, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + var recipient Recipient + err := recipient.UnmarshalText([]byte(scenario.input)) + + if !errors.Is(err, scenario.expectError) { + t.Fatalf("expected error for scenario '%s': %v, got: %v", scenario.name, scenario.expectError, err) + } + + if scenario.expectError == nil && recipient != scenario.expected { + t.Fatalf("expected recipient for scenario '%s': %+v, got: %+v", scenario.name, scenario.expected, recipient) + } + + if scenario.expectError == nil { + marshaled, err := recipient.MarshalText() + if err != nil { + t.Fatalf("unexpected error during marshaling for scenario '%s': %v", scenario.name, err) + } + expectedMarshaled := scenario.input + if strings.Contains(scenario.input, ":") == false { + expectedMarshaled = "id:" + scenario.input + } + if string(marshaled) != expectedMarshaled { + t.Fatalf("expected marshaled text for scenario '%s': %s, got: %s", scenario.name, expectedMarshaled, string(marshaled)) + } + } + }) + } +} + +func TestRecipient_Validate(t *testing.T) { + scenarios := []struct { + name string + input Recipient + expectError error + }{ + { + name: "empty recipient", + input: Recipient{Type: defaultRecipientType, Value: ""}, + expectError: errInvalidRecipientFormat, + }, + { + name: "valid id recipient", + input: Recipient{Type: RecipientTypeID, Value: "ABCDEFGH"}, + expectError: nil, + }, + { + name: "valid phone recipient", + input: Recipient{Type: RecipientTypePhone, Value: "+1234567890"}, + expectError: nil, + }, + { + name: "invalid phone recipient", + input: Recipient{Type: RecipientTypePhone, Value: "123-456-7890"}, + expectError: errInvalidPhoneNumberFormat, + }, + { + name: "valid email recipient", + input: Recipient{Type: RecipientTypeEmail, Value: "mail@test.com"}, + expectError: nil, + }, + { + name: "invalid email recipient", + input: Recipient{Type: RecipientTypeEmail, Value: "mailtest.com"}, + expectError: errInvalidEmailAddressFormat, + }, + { + name: "invalid recipient type", + input: Recipient{Type: RecipientTypeInvalid, Value: "value"}, + expectError: errInvalidRecipientType, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + err := scenario.input.Validate() + + if !errors.Is(err, scenario.expectError) { + t.Fatalf("expected error for scenario '%s': %v, got: %v", scenario.name, scenario.expectError, err) + } + }) + } +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + input Config + expected error + }{ + { + name: "valid config", + input: Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}}, + }, + expected: nil, + }, + { + name: "missing ApiIdentity", + input: Config{ + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}}, + }, + expected: errAPIIdentityMissing, + }, + { + name: "missing ApiAuthSecret", + input: Config{ + APIIdentity: "12345678", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}}, + }, + expected: rrrApiAuthSecretMissing, + }, + { + name: "missing Recipients", + input: Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + }, + expected: errRecipientsMissing, + }, + { + name: "invalid ApiIdentity", + input: Config{ + APIIdentity: "invalid-id", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}}, + }, + expected: errInvalidThreemaID, + }, + { + name: "invalid ID Recipient", + input: Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "invalid-id", Type: RecipientTypeID}}, + }, + expected: errInvalidThreemaID, + }, + { + name: "invalid Phone Recipient", + input: Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "a12345", Type: RecipientTypePhone}}, + }, + expected: errInvalidPhoneNumberFormat, + }, + { + name: "invalid Email Recipient", + input: Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "invalid-email", Type: RecipientTypeEmail}}, + }, + expected: errInvalidEmailAddressFormat, + }, + { + name: "too many Recipients in basic mode", + input: Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}, {Value: "ABCDEFGH", Type: RecipientTypeID}}, + }, + expected: errRecipientsTooMany, + }, + { + name: "not implemented E2EE mode", + input: Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}}, + PrivateKey: "someprivatekey", + }, + expected: errE2EENotImplemented, + }, + { + name: "not implemented E2EE bulk mode", + input: Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}, {Value: "ABCDEFGH", Type: RecipientTypeID}}, + PrivateKey: "someprivatekey", + }, + expected: errE2EENotImplemented, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.input.Validate() + if errors.Is(err, test.expected) == false { + t.Errorf("expected '%v', got '%v'", test.expected, err) + } + }) + } +} + +func TestConfig_Merge(t *testing.T) { + original := Config{ + APIBaseURL: "https://api.threema.ch", + APIIdentity: "12345678", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}}, + APIAuthSecret: "authsecret", + } + override := Config{ + APIBaseURL: "https://custom.api.threema.ch", + APIIdentity: "ABCDEFGH", + Recipients: []Recipient{{Value: "HGFEDCBA", Type: RecipientTypeID}}, + APIAuthSecret: "newauthsecret", + } + + change := original + change.Merge(&Config{}) // Merging with empty config should not change anything + if change.APIBaseURL != original.APIBaseURL { + t.Errorf("expected ApiBaseUrl to remain %s, got %s", original.APIBaseURL, change.APIBaseURL) + } + if change.Mode != original.Mode { + t.Errorf("expected Mode to remain %v, got %v", original.Mode, change.Mode) + } + if change.APIIdentity != original.APIIdentity { + t.Errorf("expected ApiIdentity to remain %s, got %s", original.APIIdentity, change.APIIdentity) + } + if len(change.Recipients) != len(original.Recipients) || change.Recipients[0] != original.Recipients[0] { + t.Errorf("expected Recipients to remain %v, got %v", original.Recipients, change.Recipients) + } + if change.APIAuthSecret != original.APIAuthSecret { + t.Errorf("expected ApiAuthSecret to remain %s, got %s", original.APIAuthSecret, change.APIAuthSecret) + } + + change = original + change.Merge(&override) + if change.APIBaseURL != override.APIBaseURL { + t.Errorf("expected ApiBaseUrl to be %s, got %s", override.APIBaseURL, change.APIBaseURL) + } + if change.APIIdentity != override.APIIdentity { + t.Errorf("expected ApiIdentity to be %s, got %s", override.APIIdentity, change.APIIdentity) + } + if len(change.Recipients) != len(override.Recipients) || change.Recipients[0] != override.Recipients[0] { + t.Errorf("expected Recipients to be %v, got %v", override.Recipients, change.Recipients) + } + if change.APIAuthSecret != override.APIAuthSecret { + t.Errorf("expected ApiAuthSecret to be %s, got %s", override.APIAuthSecret, change.APIAuthSecret) + } +} + +func TestAlertProvider_Validate(t *testing.T) { + if err := (&AlertProvider{ + DefaultConfig: Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}}, + }, + }).Validate(); err != nil { + t.Errorf("expected valid config to not return an error, got %v", err) + } + + if err := (&AlertProvider{ + DefaultConfig: Config{ + APIIdentity: "", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}}, + }, + }).Validate(); !errors.Is(err, errAPIIdentityMissing) { + t.Errorf("expected missing ApiIdentity to return %v, got %v", errAPIIdentityMissing, err) + } +} + +func TestAlertProvider_Send(t *testing.T) { + testAlertDescription := "Test alert" + provider := &AlertProvider{ + DefaultConfig: Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeID}}, + }, + Overrides: []Override{{Group: "default", Config: Config{ + APIIdentity: "HGFEDCBA", + }}}, + } + ep := &endpoint.Endpoint{Group: "default"} + alert := &alert.Alert{Description: &testAlertDescription, ProviderOverride: map[string]any{ + "ApiAuthSecret": "someothersecret", + }} + result := &endpoint.Result{Success: false} + + err := provider.Send(ep, alert, result, false) + if err == nil || strings.Contains(err.Error(), "401") == false { + t.Error("expected error due to invalid credentials, got nil") + } +} + +func checkStringOccurenceCount(t *testing.T, body, expectedString string, expectedOccurrences int) { + t.Helper() + actualOccurrences := strings.Count(body, expectedString) + if actualOccurrences != expectedOccurrences { + t.Errorf("expected body to contain '%s' %d times, got %d: %s", expectedString, expectedOccurrences, actualOccurrences, body) + } +} + +func TestAlertProvider_buildMessageBody(t *testing.T) { + testAlertDescription := "Test alert" + provider := &AlertProvider{} + ep := &endpoint.Endpoint{Name: "Test Endpoint", Group: "Custom Group"} + alert := &alert.Alert{Description: &testAlertDescription} + result := &endpoint.Result{Success: false, ConditionResults: []*endpoint.ConditionResult{ + {Condition: "[CONNECTED] == true", Success: true}, + {Condition: "[STATUS] == 200", Success: false}, + }, Errors: []string{ + "Failed to connect to host", + }} + + body := provider.buildMessageBody(ep, alert, result, false) + checkStringOccurenceCount(t, body, "Custom Group/Test Endpoint", 1) + checkStringOccurenceCount(t, body, "triggered", 1) + checkStringOccurenceCount(t, body, "🚨", 1) + checkStringOccurenceCount(t, body, "✅", 1) + checkStringOccurenceCount(t, body, "❌", 2) + checkStringOccurenceCount(t, body, "Failed to connect to host", 1) + + result.Success = true + result.ConditionResults[1].Success = true + result.Errors = nil + + body = provider.buildMessageBody(ep, alert, result, true) + checkStringOccurenceCount(t, body, "Custom Group/Test Endpoint", 1) + checkStringOccurenceCount(t, body, "resolved", 1) + checkStringOccurenceCount(t, body, "✅", 1) + checkStringOccurenceCount(t, body, "🚨", 0) + checkStringOccurenceCount(t, body, "❌", 0) +} + +func TestAlertProvider_prepareRequest(t *testing.T) { + provider := &AlertProvider{} + cfg := Config{ + APIIdentity: "12345678", + APIAuthSecret: "authsecret", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypePhone}}, + } + cfg.Validate() + body := "Test message body" + + request, err := provider.prepareRequest(&cfg, body) + if err != nil { + t.Errorf("expected no error preparing request, got %v", err) + } + if request.Method != "POST" { + t.Errorf("expected request method to be POST, got %s", request.Method) + } + expectedUrl := cfg.APIBaseURL + "/send_simple" + if request.URL.String() != expectedUrl { + t.Errorf("expected request URL to be %s, got %s", expectedUrl, request.URL.String()) + } + err = request.ParseForm() + if err != nil { + t.Errorf("expected no error parsing form, got %v", err) + } + if request.PostForm.Get("from") != cfg.APIIdentity { + t.Errorf("expected 'from' to be %s, got %s", cfg.APIIdentity, request.PostForm.Get("from")) + } + if request.PostForm.Get("phone") != cfg.Recipients[0].Value { + t.Errorf("expected 'phone' to be %s, got %s", cfg.Recipients[0].Value, request.PostForm.Get("to")) + } + if request.PostForm.Get("text") != body { + t.Errorf("expected 'text' to be %s, got %s", body, request.PostForm.Get("text")) + } + if request.PostForm.Get("secret") != cfg.APIAuthSecret { + t.Errorf("expected 'secret' to be %s, got %s", cfg.APIAuthSecret, request.PostForm.Get("secret")) + } + + cfg.Recipients[0] = Recipient{Value: "test@mail.com", Type: RecipientTypeEmail} + request, err = provider.prepareRequest(&cfg, body) + if err != nil { + t.Errorf("expected no error preparing request, got %v", err) + } + err = request.ParseForm() + if err != nil { + t.Errorf("expected no error parsing form, got %v", err) + } + if request.PostForm.Get("email") != cfg.Recipients[0].Value { + t.Errorf("expected 'email' to be %s, got %s", cfg.Recipients[0].Value, request.PostForm.Get("to")) + } +} + +func TestAlertProvider_GetDefaultAlert(t *testing.T) { + if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { + t.Error("expected default alert to be not nil") + } + if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil { + t.Error("expected default alert to be nil") + } +} diff --git a/config/config.go b/config/config.go index 1597e9ceb..1debebc62 100644 --- a/config/config.go +++ b/config/config.go @@ -629,6 +629,7 @@ func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi alert.TypeTeams, alert.TypeTeamsWorkflows, alert.TypeTelegram, + alert.TypeThreemaGateway, alert.TypeTwilio, alert.TypeVonage, alert.TypeWebex, diff --git a/config/config_test.go b/config/config_test.go index 216cd4ec4..0e95c5c69 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -46,6 +46,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows" "github.com/TwiN/gatus/v5/alerting/provider/telegram" + "github.com/TwiN/gatus/v5/alerting/provider/threemagateway" "github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/vonage" "github.com/TwiN/gatus/v5/alerting/provider/webex" @@ -1889,6 +1890,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { Telegram: &telegram.AlertProvider{}, Teams: &teams.AlertProvider{}, TeamsWorkflows: &teamsworkflows.AlertProvider{}, + ThreemaGateway: &threemagateway.AlertProvider{}, Twilio: &twilio.AlertProvider{}, Vonage: &vonage.AlertProvider{}, Webex: &webex.AlertProvider{}, @@ -1934,6 +1936,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { {alertType: alert.TypeTelegram, expected: alertingConfig.Telegram}, {alertType: alert.TypeTeams, expected: alertingConfig.Teams}, {alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows}, + {alertType: alert.TypeThreemaGateway, expected: alertingConfig.ThreemaGateway}, {alertType: alert.TypeTwilio, expected: alertingConfig.Twilio}, {alertType: alert.TypeVonage, expected: alertingConfig.Vonage}, {alertType: alert.TypeWebex, expected: alertingConfig.Webex}, diff --git a/watchdog/alerting_test.go b/watchdog/alerting_test.go index 7c0fbad54..4b17ea758 100644 --- a/watchdog/alerting_test.go +++ b/watchdog/alerting_test.go @@ -25,6 +25,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/teams" "github.com/TwiN/gatus/v5/alerting/provider/telegram" + "github.com/TwiN/gatus/v5/alerting/provider/threemagateway" "github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/vonage" "github.com/TwiN/gatus/v5/alerting/provider/zapier" @@ -455,6 +456,20 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "threemagateway", + AlertType: alert.TypeThreemaGateway, + AlertingConfig: &alerting.Config{ + ThreemaGateway: &threemagateway.AlertProvider{ + DefaultConfig: threemagateway.Config{ + APIBaseURL: "test-url", + APIIdentity: "87654321", + Recipients: []threemagateway.Recipient{{Value: "12345678", Type: threemagateway.RecipientTypeID}}, + APIAuthSecret: "test-secret", + }, + }, + }, + }, { Name: "twilio", AlertType: alert.TypeTwilio,