Merge branch 'feature/sandbar-integration'

# Conflicts:
#	store.go
This commit is contained in:
Veillax committed 2026-10-27 17:09:40 -06:00
commit 54bd151ddd
10 files changed
+810 -25

No files matched your search

+59 -8
View File
@@ -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