A Sandbar topic channel publishes alerts as ntfy-style messages (API key optional). Under Server, Settings, Hammerhead can create, update and remove a Sandbar monitor per enabled resource with health checks, linked by resource id so monitors made by hand are left alone. The key is sealed at rest. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
210 lines
6.4 KiB
Go
210 lines
6.4 KiB
Go
package hammerhead
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// The same handlers serve an organization's channels and the server-level ones
|
|
// (for edges, which belong to nobody); only the scope differs.
|
|
|
|
type alertScope struct {
|
|
org string // "" = server level
|
|
trusted bool // may allow private webhook targets
|
|
actor string
|
|
}
|
|
|
|
type channelView struct {
|
|
AlertChannel
|
|
Preview string `json:"preview"`
|
|
}
|
|
|
|
func preview(ch AlertChannel) string {
|
|
if ch.Kind == "email" {
|
|
return ch.Target
|
|
}
|
|
if u, err := url.Parse(ch.Target); err == nil && ch.Kind == "topic" {
|
|
return u.Scheme + "://" + u.Host + "/" + ch.Topic
|
|
}
|
|
if u, err := url.Parse(ch.Target); err == nil {
|
|
return u.Scheme + "://" + u.Host + "/…"
|
|
}
|
|
return "webhook"
|
|
}
|
|
|
|
type alertRow struct {
|
|
Key string `json:"key"`
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Message string `json:"message"`
|
|
Since time.Time `json:"since"`
|
|
Fired bool `json:"fired"`
|
|
}
|
|
|
|
func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
|
|
s.mu.Lock()
|
|
chans := []channelView{}
|
|
for _, c := range s.st.Channels {
|
|
if c.OrgID == sc.org {
|
|
v := channelView{AlertChannel: *c, Preview: preview(*c)}
|
|
v.Target, v.Token = "", "" // webhook URLs often contain tokens
|
|
if c.Kind == "email" {
|
|
v.Target = c.Target
|
|
}
|
|
chans = append(chans, v)
|
|
}
|
|
}
|
|
active := []alertRow{}
|
|
for _, a := range s.alerts {
|
|
if a.OrgID == sc.org {
|
|
active = append(active, alertRow{a.Key, a.Kind, a.Name, a.Message, a.Since, a.Fired})
|
|
}
|
|
}
|
|
sort.Slice(active, func(i, j int) bool { return active[i].Since.Before(active[j].Since) })
|
|
settings := map[string]int{"debounceMin": 2, "repeatHours": 0}
|
|
if o := s.findOrg(sc.org); o != nil {
|
|
if o.AlertDebounceMin > 0 {
|
|
settings["debounceMin"] = o.AlertDebounceMin
|
|
}
|
|
settings["repeatHours"] = o.AlertRepeatHours
|
|
}
|
|
mailOK := s.st.Settings.SMTP.Host != ""
|
|
s.mu.Unlock()
|
|
writeJSON(w, 200, map[string]any{"channels": chans, "active": active, "history": s.db.queryAlertEvents(sc.org, 100), "settings": settings, "emailConfigured": mailOK})
|
|
}
|
|
|
|
func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
|
var in AlertChannel
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
id := r.PathValue("id")
|
|
var existing *AlertChannel
|
|
if id != "" {
|
|
for _, c := range s.st.Channels {
|
|
if c.ID == id && c.OrgID == sc.org {
|
|
existing = c
|
|
}
|
|
}
|
|
if existing == nil {
|
|
fail(w, 404, errors.New("no such channel"))
|
|
return
|
|
}
|
|
if in.Target == "" {
|
|
in.Target = existing.Target // blank keeps the saved URL/address
|
|
}
|
|
if id != "" && in.Token == "" && (in.Kind == "sandbar" || in.Kind == "topic") {
|
|
in.Token = existing.Token // blank keeps the saved API key
|
|
}
|
|
}
|
|
trusted := sc.trusted
|
|
if err := s.validateChannel(&in, trusted); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
in.OrgID = sc.org
|
|
if existing != nil {
|
|
in.ID = id
|
|
*existing = in
|
|
} else {
|
|
in.ID = newID()
|
|
s.st.Channels = append(s.st.Channels, &in)
|
|
}
|
|
s.save()
|
|
s.audit(sc.org, sc.actor, "alert.channel.save", in.Name)
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
|
id := r.PathValue("id")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.st.Channels = filter(s.st.Channels, func(c *AlertChannel) bool { return !(c.ID == id && c.OrgID == sc.org) })
|
|
s.save()
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
|
id := r.PathValue("id")
|
|
s.mu.Lock()
|
|
var ch *AlertChannel
|
|
for _, c := range s.st.Channels {
|
|
if c.ID == id && c.OrgID == sc.org {
|
|
cp := *c
|
|
ch = &cp
|
|
}
|
|
}
|
|
name := "server"
|
|
if o := s.findOrg(sc.org); o != nil {
|
|
name = o.Name
|
|
}
|
|
s.mu.Unlock()
|
|
if ch == nil {
|
|
fail(w, 404, errors.New("no such channel"))
|
|
return
|
|
}
|
|
now := time.Now()
|
|
if err := s.send(*ch, alertNote{Status: "test", Org: name, Kind: "test", Name: "Test alert", Message: "test", Since: now, Time: now}); err != nil {
|
|
fail(w, 400, err)
|
|
return
|
|
}
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) alertSettings(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
|
var in struct{ DebounceMin, RepeatHours int }
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
if in.DebounceMin < 1 || in.DebounceMin > 120 || in.RepeatHours < 0 || in.RepeatHours > 168 {
|
|
fail(w, 400, errors.New("Wait 1 to 120 minutes before alerting, and remind every 0 to 168 hours (0 means no reminders)."))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
o := s.findOrg(sc.org)
|
|
if o == nil {
|
|
fail(w, 404, errors.New("organization settings only"))
|
|
return
|
|
}
|
|
o.AlertDebounceMin, o.AlertRepeatHours = in.DebounceMin, in.RepeatHours
|
|
s.save()
|
|
ok(w)
|
|
}
|
|
|
|
// wrappers: an organization's admins manage its channels; server admins manage the server-level ones
|
|
|
|
func (s *Server) orgAlertScope(c *orgCtx) alertScope {
|
|
return alertScope{org: c.Org.ID, trusted: c.Org.AllowLocal || c.User.ServerAdmin, actor: c.Actor}
|
|
}
|
|
|
|
func (s *Server) withOrgAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
|
|
return s.withOrg(true, func(w http.ResponseWriter, r *http.Request, c *orgCtx) { fn(w, r, s.orgAlertScope(c)) })
|
|
}
|
|
|
|
func (s *Server) withServerAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
|
|
return s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
fn(w, r, alertScope{org: "", trusted: true, actor: p.User.Email})
|
|
})
|
|
}
|
|
|
|
func (s *Server) registerAlertRoutes(mux *http.ServeMux) {
|
|
overview := func(w http.ResponseWriter, r *http.Request, sc alertScope) { s.alertOverview(w, sc) }
|
|
mux.HandleFunc("GET /api/orgs/{org}/alerts", s.withOrgAlerts(overview))
|
|
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels", s.withOrgAlerts(s.saveChannel))
|
|
mux.HandleFunc("PUT /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.saveChannel))
|
|
mux.HandleFunc("DELETE /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.deleteChannel))
|
|
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels/{id}/test", s.withOrgAlerts(s.testChannel))
|
|
mux.HandleFunc("PUT /api/orgs/{org}/alerts/settings", s.withOrgAlerts(s.alertSettings))
|
|
mux.HandleFunc("GET /api/admin/alerts", s.withServerAlerts(overview))
|
|
mux.HandleFunc("POST /api/admin/alerts/channels", s.withServerAlerts(s.saveChannel))
|
|
mux.HandleFunc("PUT /api/admin/alerts/channels/{id}", s.withServerAlerts(s.saveChannel))
|
|
mux.HandleFunc("DELETE /api/admin/alerts/channels/{id}", s.withServerAlerts(s.deleteChannel))
|
|
mux.HandleFunc("POST /api/admin/alerts/channels/{id}/test", s.withServerAlerts(s.testChannel))
|
|
}
|