diff --git a/README.md b/README.md index 233528c62..76a0255a6 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,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) @@ -861,6 +862,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). | `{}` | @@ -2241,6 +2243,38 @@ 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.send-mode` | Mode used to send the alert, currently only `basic` is supported | `"basic"` | +| `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 43bd24879..d76288419 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -110,6 +110,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 65150396b..f79d3fe6d 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -41,6 +41,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" @@ -156,6 +157,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 64084e203..b847532be 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -37,6 +37,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" @@ -126,6 +127,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) @@ -167,6 +169,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/recipient.go b/alerting/provider/threemagateway/recipient.go new file mode 100644 index 000000000..f9cde046d --- /dev/null +++ b/alerting/provider/threemagateway/recipient.go @@ -0,0 +1,115 @@ +package threemagateway + +import ( + "encoding" + "errors" + "fmt" + "strings" +) + +const ( + defaultRecipientType = RecipientTypeId +) + +var ( + 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, + } + + ErrInvalidPhoneNumberFormat = errors.New("invalid phone number: must contain only digits and may start with '+'") + ErrInvalidEmailAddressFormat = errors.New("invalid email address: must contain '@'") +) + +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: + // Basic validation for email address // TODO#1464: improve email validation + if !strings.Contains(r.Value, "@") { + return ErrInvalidEmailAddressFormat + } + default: + return ErrInvalidRecipientType + } + 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 +} diff --git a/alerting/provider/threemagateway/recipient_test.go b/alerting/provider/threemagateway/recipient_test.go new file mode 100644 index 000000000..5721ce722 --- /dev/null +++ b/alerting/provider/threemagateway/recipient_test.go @@ -0,0 +1,134 @@ +package threemagateway + +import ( + "errors" + "strings" + "testing" +) + +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) + } + }) + } +} diff --git a/alerting/provider/threemagateway/sendmode.go b/alerting/provider/threemagateway/sendmode.go new file mode 100644 index 000000000..14ac8c61f --- /dev/null +++ b/alerting/provider/threemagateway/sendmode.go @@ -0,0 +1,56 @@ +package threemagateway + +import ( + "encoding" + "errors" + "fmt" +) + +const ( + defaultMode = "basic" // TODO#1464: Should the default be e2ee event though it is not implemented yet to avoid future breaking or bad default behavior? +) + +var ( + ErrModeTypeInvalid = fmt.Errorf("invalid mode, must be one of: %s", joinKeys(validModeTypes, ", ")) + ErrNotImplementedMode = errors.New("configured mode is not implemented") + validModeTypes = map[string]ModeType{ + "basic": ModeTypeBasic, + "e2ee": ModeTypeE2EE, + "e2ee-bulk": ModeTypeE2EEBulk, + } +) + +type ModeType int + +const ( + ModeTypeInvalid ModeType = iota + ModeTypeBasic + ModeTypeE2EE + ModeTypeE2EEBulk +) + +type SendMode struct { + Value string `yaml:"-"` + Type ModeType `yaml:"-"` +} + +var _ encoding.TextUnmarshaler = (*SendMode)(nil) +var _ encoding.TextMarshaler = (*SendMode)(nil) + +func (m *SendMode) UnmarshalText(text []byte) error { + t := string(text) + if len(t) == 0 { + t = defaultMode + } + m.Value = t + if val, ok := validModeTypes[t]; ok { + m.Type = val + return nil + } + m.Type = ModeTypeInvalid + return ErrModeTypeInvalid +} + +func (m SendMode) MarshalText() ([]byte, error) { + return []byte(m.Value), nil +} diff --git a/alerting/provider/threemagateway/sendmode_test.go b/alerting/provider/threemagateway/sendmode_test.go new file mode 100644 index 000000000..f1885911b --- /dev/null +++ b/alerting/provider/threemagateway/sendmode_test.go @@ -0,0 +1,69 @@ +package threemagateway + +import ( + "errors" + "testing" +) + +func TestSendMode_UnmarshalText_And_MarshalText(t *testing.T) { + scenarios := []struct { + name string + input string + expected SendMode + expectError error + }{ + { + name: "default mode", + input: "", + expected: SendMode{Value: defaultMode, Type: validModeTypes[defaultMode]}, + expectError: nil, + }, + { + name: "basic mode", + input: "basic", + expected: SendMode{Value: "basic", Type: ModeTypeBasic}, + expectError: nil, + }, + { + name: "e2ee mode", + input: "e2ee", + expected: SendMode{Value: "e2ee", Type: ModeTypeE2EE}, + expectError: nil, + }, + { + name: "invalid mode", + input: "invalid-mode", + expected: SendMode{Value: "invalid-mode", Type: ModeTypeInvalid}, + expectError: ErrModeTypeInvalid, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + var mode SendMode + err := mode.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 && mode != scenario.expected { + t.Fatalf("expected mode for scenario '%s': %+v, got: %+v", scenario.name, scenario.expected, mode) + } + + if scenario.expectError == nil { + marshaled, err := mode.MarshalText() + if err != nil { + t.Fatalf("unexpected error during marshaling for scenario '%s': %v", scenario.name, err) + } + expectedMarshaled := scenario.input + if len(scenario.input) == 0 { + expectedMarshaled = defaultMode + } + if string(marshaled) != expectedMarshaled { + t.Fatalf("expected marshaled mode for scenario '%s': '%s', got: '%s'", scenario.name, expectedMarshaled, string(marshaled)) + } + } + }) + } +} diff --git a/alerting/provider/threemagateway/threemagateway.go b/alerting/provider/threemagateway/threemagateway.go new file mode 100644 index 000000000..2805d14af --- /dev/null +++ b/alerting/provider/threemagateway/threemagateway.go @@ -0,0 +1,257 @@ +package threemagateway + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "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" +) + +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") +) + +type Config struct { + ApiBaseUrl string `yaml:"api-base-url"` + Mode *SendMode `yaml:"send-mode"` + ApiIdentity string `yaml:"api-identity"` + Recipients []Recipient `yaml:"recipients"` // TODO#1470: Remove comment: This is an array to support bulk sending in e2ee-bulk mode once implemented + ApiAuthSecret string `yaml:"auth-secret"` +} + +func (cfg *Config) Validate() error { + // Validate API Base URL + if len(cfg.ApiBaseUrl) == 0 { + cfg.ApiBaseUrl = defaultApiBaseUrl + } + + // Validate Mode + if cfg.Mode == nil { + cfg.Mode = &SendMode{} + cfg.Mode.UnmarshalText([]byte{}) + } + switch cfg.Mode.Type { + case ModeTypeInvalid: + return ErrModeTypeInvalid + case ModeTypeE2EE, ModeTypeE2EEBulk: + return ErrNotImplementedMode // TODO#1470: implement E2EE and E2EE-Bulk modes + } + + // 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 + var modeType = cfg.Mode.Type + if modeType == ModeTypeBasic && len(cfg.Recipients) > 1 { // TODO#1470 Handle non bulk e2ee modes properly once implemented + return ErrRecipientsTooMany + } else if len(cfg.Recipients) == 0 { + return ErrRecipientsMissing + } + for _, recipient := range cfg.Recipients { + if err := recipient.Validate(); err != nil { + return fmt.Errorf("recipients: %s: %w", recipient.Value, err) + } + // TODO#1470: Either support recipient types other than id in e2ee modes or handle the error properly once those modes are implemented + } + + // 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 override.Mode != nil { + cfg.Mode = override.Mode + } + 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 { + // TODO#1464 Validate overrides? + 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.Type { + case ModeTypeBasic: + requestUrl += "/send_simple" + case ModeTypeE2EE, ModeTypeE2EEBulk: + return nil, ErrNotImplementedMode // TODO#1470: implement E2EE and E2EE-Bulk modes + default: + return nil, ErrNotImplementedMode + } + + 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.Type { + case ModeTypeBasic, ModeTypeE2EE: + return nil + case ModeTypeE2EEBulk: + return nil // TODO#1470: Add correct handling once mode is implemented (check success fields in response body) + } + case http.StatusBadRequest: + switch cfg.Mode.Type { + case ModeTypeBasic, ModeTypeE2EE: + return fmt.Errorf("%s: Invalid recipient or Threema Account not set up for %s mode", response.Status, cfg.Mode.Value) + case ModeTypeE2EEBulk: + // 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.Type == ModeTypeBasic { + 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..bd6bfd66a --- /dev/null +++ b/alerting/provider/threemagateway/threemagateway_test.go @@ -0,0 +1,327 @@ +package threemagateway + +import ( + "errors" + "strings" + "testing" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/config/endpoint" +) + +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: ErrApiAuthSecretMissing, + }, + { + name: "missing Recipients", + input: Config{ + ApiIdentity: "12345678", + ApiAuthSecret: "authsecret", + }, + expected: ErrRecipientsMissing, + }, + { + name: "invalid Mode", + input: Config{ + ApiIdentity: "12345678", + ApiAuthSecret: "authsecret", + Mode: &SendMode{Value: "invalid-mode", Type: ModeTypeInvalid}, + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}}, + }, + expected: ErrModeTypeInvalid, + }, + { + 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", + Mode: &SendMode{Value: "basic", Type: ModeTypeBasic}, + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}, {Value: "ABCDEFGH", Type: RecipientTypeId}}, + }, + expected: ErrRecipientsTooMany, + }, + { + name: "not implemented mode", + input: Config{ + ApiIdentity: "12345678", + ApiAuthSecret: "authsecret", + Mode: &SendMode{Value: "e2ee", Type: ModeTypeE2EE}, + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}}, + }, + expected: ErrNotImplementedMode, + }, + } + + 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", + Mode: &SendMode{Value: "basic", Type: ModeTypeBasic}, + ApiIdentity: "12345678", + Recipients: []Recipient{{Value: "87654321", Type: RecipientTypeId}}, + ApiAuthSecret: "authsecret", + } + override := Config{ + ApiBaseUrl: "https://custom.api.threema.ch", + Mode: &SendMode{Value: "e2ee", Type: ModeTypeE2EE}, + 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.Mode.Value != override.Mode.Value || change.Mode.Type != override.Mode.Type { + t.Errorf("expected Mode to be %v, got %v", override.Mode, change.Mode) + } + 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/alerting/provider/threemagateway/util.go b/alerting/provider/threemagateway/util.go new file mode 100644 index 000000000..24ef20237 --- /dev/null +++ b/alerting/provider/threemagateway/util.go @@ -0,0 +1,23 @@ +package threemagateway + +import ( + "errors" + "maps" + "slices" + "strings" +) + +var ( + ErrInvalidThreemaId = errors.New("Must be 8 characters long and alphabetic characters must be uppercase") +) + +func joinKeys[V any](m map[string]V, separator string) string { + return strings.Join(slices.Collect(maps.Keys(m)), separator) +} + +func validateThreemaId(id string) error { + if len(id) != 8 || strings.ToUpper(id) != id { + return ErrInvalidThreemaId + } + return nil +} diff --git a/config/config.go b/config/config.go index 4d9724b54..ec9b1936e 100644 --- a/config/config.go +++ b/config/config.go @@ -628,6 +628,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 811f0ed1c..7d90414d5 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -45,6 +45,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" @@ -1887,6 +1888,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{}, @@ -1931,6 +1933,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 2c9ccd922..03929dae4 100644 --- a/watchdog/alerting_test.go +++ b/watchdog/alerting_test.go @@ -24,6 +24,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" @@ -454,6 +455,21 @@ func TestHandleAlertingWithProviderThatReturnsAnError(t *testing.T) { }, }, }, + { + Name: "threemagateway", + AlertType: alert.TypeThreemaGateway, + AlertingConfig: &alerting.Config{ + ThreemaGateway: &threemagateway.AlertProvider{ + DefaultConfig: threemagateway.Config{ + ApiBaseUrl: "test-url", + Mode: nil, + ApiIdentity: "87654321", + Recipients: []threemagateway.Recipient{{Value: "12345678", Type: threemagateway.RecipientTypeId}}, + ApiAuthSecret: "test-secret", + }, + }, + }, + }, { Name: "twilio", AlertType: alert.TypeTwilio,