Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/startup.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/target/goalert/app/lifecycle"
"github.com/target/goalert/expflag"
"github.com/target/goalert/notification/email"
"github.com/target/goalert/notification/googlechat"
"github.com/target/goalert/notification/webhook"
"github.com/target/goalert/retry"

Expand Down Expand Up @@ -95,6 +96,8 @@ func (app *App) startup(ctx context.Context) error {
app.DestRegistry.RegisterProvider(ctx, app.slackChan.DMSender())
app.DestRegistry.RegisterProvider(ctx, app.slackChan.UserGroupSender())
app.DestRegistry.RegisterProvider(ctx, webhook.NewSender(ctx, app.httpClient))
app.DestRegistry.RegisterProvider(ctx, &webhook.CustomSender{Client: app.httpClient})
app.DestRegistry.RegisterProvider(ctx, googlechat.NewSender(ctx, app.httpClient))
if app.cfg.StubNotifiers {
app.DestRegistry.StubNotifiers()
}
Expand Down
8 changes: 8 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ type Config struct {
AllowedURLs []string `public:"true" info:"If set, allows webhooks for these domains only."`
}

GoogleChat struct {
Enable bool `public:"true" info:"Enables Google Chat as a notification destination."`
}

CustomWebhook struct {
Enable bool `public:"true" info:"Enables Custom Webhook as a notification destination."`
}

Feedback struct {
Enable bool `public:"true" info:"Enables Feedback link in nav bar."`
OverrideURL string `public:"true" info:"Use a custom URL for Feedback link in nav bar."`
Expand Down
16 changes: 16 additions & 0 deletions graphql2/mapconfig.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

201 changes: 201 additions & 0 deletions notification/webhook/customsender.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package webhook

import (
"bytes"
"context"
"fmt"
"io"
"mime"
"net/http"
"strings"
"text/template"
"time"

"github.com/target/goalert/config"
"github.com/target/goalert/notification"
"github.com/target/goalert/validation"
)

type CustomSender struct {
Client *http.Client
}

type customWebhookPayload struct {
MessageID string
AppName string
Type string

AlertID int
Summary string
Details string
ServiceID string
ServiceName string
Count int
LogEntry string
Code string
ScheduleID string
ScheduleName string
ScheduleURL string
Users []notification.User
Meta map[string]string
NewAlertState string
}

func (s *CustomSender) client() *http.Client {
if s != nil && s.Client != nil {
return s.Client
}
return http.DefaultClient
}

func (s *CustomSender) SendMessage(ctx context.Context, msg notification.Message) (*notification.SentMessage, error) {
cfg := config.FromContext(ctx)

webURL := msg.DestArg(FieldWebhookURL)
if !cfg.ValidWebhookURL(webURL) {
return &notification.SentMessage{
State: notification.StateFailedPerm,
StateDetails: "invalid or not allowed URL",
}, nil
}

tpl, err := template.New("body").Option("missingkey=error").Parse(msg.DestArg(FieldBodyTemplate))
if err != nil {
return &notification.SentMessage{
State: notification.StateFailedPerm,
StateDetails: err.Error(),
}, nil
}

data := renderCustomWebhookPayload(cfg, msg)
var body bytes.Buffer
if err := tpl.Execute(&body, data); err != nil {
return &notification.SentMessage{
State: notification.StateFailedPerm,
StateDetails: err.Error(),
}, nil
}

contentType := msg.DestArg(FieldContentType)
if contentType == "" {
contentType = "application/json"
}
if _, _, err := mime.ParseMediaType(contentType); err != nil {
return &notification.SentMessage{
State: notification.StateFailedPerm,
StateDetails: err.Error(),
}, nil
}

ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodPost, webURL, bytes.NewReader(body.Bytes()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)

resp, err := s.client().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return &notification.SentMessage{State: notification.StateSent}, nil
}

respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if readErr != nil {
return nil, readErr
}

details := strings.TrimSpace(string(respBody))
if details != "" {
details = fmt.Sprintf("%s: %s", resp.Status, details)
} else {
details = resp.Status
}

state := notification.StateFailedPerm
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
state = notification.StateFailedTemp
}

return &notification.SentMessage{
State: state,
StateDetails: details,
}, nil
}

func renderCustomWebhookPayload(cfg config.Config, msg notification.Message) customWebhookPayload {
res := customWebhookPayload{
MessageID: msg.MsgID(),
AppName: cfg.ApplicationName(),
Type: fmt.Sprintf("%T", msg),
}

switch t := msg.(type) {
case notification.Test:
res.Type = "Test"
case notification.Verification:
res.Type = "Verification"
res.Code = t.Code
case notification.Alert:
res.Type = "Alert"
res.AlertID = t.AlertID
res.Summary = t.Summary
res.Details = t.Details
res.ServiceID = t.ServiceID
res.ServiceName = t.ServiceName
res.Meta = t.Meta
case notification.AlertBundle:
res.Type = "AlertBundle"
res.ServiceID = t.ServiceID
res.ServiceName = t.ServiceName
res.Count = t.Count
case notification.AlertStatus:
res.Type = "AlertStatus"
res.AlertID = t.AlertID
res.LogEntry = t.LogEntry
res.ServiceID = t.ServiceID
res.Summary = t.Summary
res.Details = t.Details
res.NewAlertState = alertStateText(t.NewAlertState)
case notification.ScheduleOnCallUsers:
res.Type = "ScheduleOnCallUsers"
res.ScheduleID = t.ScheduleID
res.ScheduleName = t.ScheduleName
res.ScheduleURL = t.ScheduleURL
res.Users = append([]notification.User(nil), t.Users...)
default:
res.Type = fmt.Sprintf("%T", msg)
}

return res
}

func alertStateText(state notification.AlertState) string {
switch state {
case notification.AlertStateUnacknowledged:
return "unacknowledged"
case notification.AlertStateAcknowledged:
return "acknowledged"
case notification.AlertStateClosed:
return "closed"
default:
return ""
}
}

func parseTemplate(body string) (*template.Template, error) {
if body == "" {
return nil, validation.NewFieldError(FieldBodyTemplate, "required")
}
tpl, err := template.New("body").Option("missingkey=error").Parse(body)
if err != nil {
return nil, validation.WrapError(err)
}
return tpl, nil
}
Loading