Merge branch 'feature/sandbar-integration'
# Conflicts: # store.go
This commit is contained in:
commit
54bd151ddd
10 files changed
+810
-25
No files matched your search
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,21 +24,25 @@ import (
|
||||
// message when it starts and one when it clears, and optionally a reminder.
|
||||
// Active alerts are kept in the database so a restart doesn't repeat them.
|
||||
|
||||
var topicName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
const (
|
||||
defaultDebounce = 2 * time.Minute
|
||||
defaultOffline = 90 * time.Second
|
||||
)
|
||||
|
||||
// AlertChannel is somewhere alerts are sent: an email address, a webhook, or a
|
||||
// Sandbar server (which pages whoever is on call and tracks the alert).
|
||||
// AlertChannel is somewhere alerts are sent: an email address, a webhook, a
|
||||
// Sandbar server (which pages whoever is on call and tracks the alert), or a
|
||||
// Sandbar topic (a plain message for everyone subscribed to it).
|
||||
type AlertChannel struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"` // "" = a server-level channel (for edges)
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // "email", "webhook" or "sandbar"
|
||||
Kind string `json:"kind"` // "email", "webhook", "sandbar" or "topic"
|
||||
Target string `json:"target"` // address(es), a webhook URL, or Sandbar's address (secret: URLs often carry tokens)
|
||||
Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy"
|
||||
Token string `json:"token"` // Sandbar: API key (secret, never sent back to the browser)
|
||||
Token string `json:"token"` // Sandbar: API key (secret, never sent back to the browser; optional for a topic)
|
||||
Topic string `json:"topic"` // topic: the topic to publish to
|
||||
Policy string `json:"policy"` // Sandbar: escalation policy id; blank sends the alert without paging anyone
|
||||
AllowPrivate bool `json:"allowPrivate"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -396,6 +401,8 @@ func (s *Server) send(ch AlertChannel, n alertNote) error {
|
||||
return errors.Join(errs...)
|
||||
case "sandbar":
|
||||
return s.sendSandbar(ch, n)
|
||||
case "topic":
|
||||
return s.sendTopic(ch, n)
|
||||
case "webhook":
|
||||
body, ctype, hdr := webhookBody(ch.Format, n)
|
||||
req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body))
|
||||
@@ -460,6 +467,38 @@ func (s *Server) sendSandbar(ch AlertChannel, n alertNote) error {
|
||||
"detail": "Organization: " + n.Org + "\nSince: " + n.Since.Format(time.RFC1123), "severity": "critical", "policy": ch.Policy})
|
||||
}
|
||||
|
||||
// sendTopic publishes the alert as a message on a Sandbar topic (ntfy's request
|
||||
// works too), so everyone subscribed, including the ntfy phone apps, sees it. A
|
||||
// topic has no alert to close, so a recovery is a second, calmer message.
|
||||
func (s *Server) sendTopic(ch AlertChannel, n alertNote) error {
|
||||
prio, tags := "urgent", "rotating_light"
|
||||
if n.Status != "firing" {
|
||||
prio, tags = "default", "white_check_mark"
|
||||
}
|
||||
req, err := http.NewRequest("POST", strings.TrimRight(ch.Target, "/")+"/"+ch.Topic, strings.NewReader(n.text()+"\n\nOrganization: "+n.Org))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Title", n.subject())
|
||||
req.Header.Set("Priority", prio)
|
||||
req.Header.Set("Tags", tags)
|
||||
if ch.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+ch.Token)
|
||||
}
|
||||
resp, err := netguard.Client(ch.AllowPrivate).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch {
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
return errors.New("Sandbar rejected the API key")
|
||||
case resp.StatusCode >= 300:
|
||||
return fmt.Errorf("Sandbar answered %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) {
|
||||
text := n.subject() + "\n" + n.text()
|
||||
switch format {
|
||||
@@ -490,7 +529,7 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
||||
return errors.New("enter one or more email addresses, separated by commas")
|
||||
}
|
||||
}
|
||||
ch.Format, ch.Token, ch.Policy, ch.AllowPrivate = "", "", "", false
|
||||
ch.Format, ch.Token, ch.Policy, ch.Topic, ch.AllowPrivate = "", "", "", "", false
|
||||
case "sandbar":
|
||||
u, err := url.Parse(ch.Target)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
@@ -499,7 +538,19 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
||||
if ch.Token = strings.TrimSpace(ch.Token); ch.Token == "" {
|
||||
return errors.New("enter an API key from Sandbar's Account page")
|
||||
}
|
||||
ch.Policy, ch.Format = strings.TrimSpace(ch.Policy), ""
|
||||
ch.Policy, ch.Format, ch.Topic = strings.TrimSpace(ch.Policy), "", ""
|
||||
if ch.AllowPrivate && !trusted {
|
||||
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||
}
|
||||
case "topic":
|
||||
u, err := url.Parse(ch.Target)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return errors.New("enter Sandbar's address, such as https://sandbar.example.com")
|
||||
}
|
||||
if ch.Topic = strings.TrimSpace(ch.Topic); !topicName.MatchString(ch.Topic) {
|
||||
return errors.New("a topic is 1 to 64 letters, digits, - or _")
|
||||
}
|
||||
ch.Token, ch.Policy, ch.Format = strings.TrimSpace(ch.Token), "", ""
|
||||
if ch.AllowPrivate && !trusted {
|
||||
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||
}
|
||||
@@ -508,7 +559,7 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return errors.New("the webhook must be an http:// or https:// URL")
|
||||
}
|
||||
ch.Token, ch.Policy = "", ""
|
||||
ch.Token, ch.Policy, ch.Topic = "", "", ""
|
||||
switch ch.Format {
|
||||
case "json", "slack", "discord", "ntfy":
|
||||
case "":
|
||||
@@ -520,7 +571,7 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error {
|
||||
return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization")
|
||||
}
|
||||
default:
|
||||
return errors.New("a channel is an email, a webhook or a Sandbar server")
|
||||
return errors.New("a channel is an email, a webhook, a Sandbar server or a Sandbar topic")
|
||||
}
|
||||
if ch.Name == "" {
|
||||
ch.Name = ch.Kind
|
||||
|
||||
Reference in new issue
Block a user