Files
Veillax 4cb24f7085
CI / test (push) Successful in 1m58s
Merge branch 'feature/mobile-push'
# Conflicts:
#	internal/sandbar/api.go
2026-10-27 17:14:00 -06:00

878 lines
26 KiB
Go

package sandbar
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
// Handler returns the HTTP handler for the whole app.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// pages
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { servePage(w, indexHTML) })
s.statusRoutes(mux)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
mux.HandleFunc("GET /metrics", s.metrics)
mux.HandleFunc("GET /script.js", s.serveTracker)
mux.HandleFunc("POST /api/send", s.collect)
mux.HandleFunc("OPTIONS /api/send", s.collect)
mux.HandleFunc("GET /api/v1/hb/{token}", s.heartbeat)
mux.HandleFunc("POST /api/v1/hb/{token}", s.heartbeat)
// session
mux.HandleFunc("GET /api/v1/state", s.getState)
mux.HandleFunc("POST /api/v1/setup", s.setup)
mux.HandleFunc("POST /api/v1/login", s.apiLogin)
mux.HandleFunc("POST /api/v1/logout", func(w http.ResponseWriter, r *http.Request) { s.logout(w, r); writeJSON(w, 200, ok) })
// mobile companion
mux.HandleFunc("POST /api/v1/mobile/login", s.mobileLogin)
mux.HandleFunc("GET /api/v1/push/key", s.auth(false, s.pushKeyHTTP))
mux.HandleFunc("GET /api/v1/push/subscriptions", s.auth(false, s.listPushSubs))
mux.HandleFunc("POST /api/v1/push/subscriptions", s.auth(false, s.savePushSub))
mux.HandleFunc("DELETE /api/v1/push/subscriptions/{id}", s.auth(false, s.deletePushSub))
mux.HandleFunc("POST /api/v1/push/test", s.auth(false, s.testPush))
mux.HandleFunc("GET /api/v1/hammerhead/edges", s.auth(false, s.hammerheadEdges))
// topics, ntfy-style
mux.HandleFunc("POST /{topic}", s.auth(false, s.publishHTTP))
mux.HandleFunc("PUT /{topic}", s.auth(false, s.publishHTTP))
mux.HandleFunc("GET /{topic}/sse", s.auth(false, s.subscribeHTTP))
mux.HandleFunc("GET /{topic}/json", s.auth(false, s.subscribeHTTP))
mux.HandleFunc("GET /api/v1/topics/{topic}/messages", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
writeJSON(w, 200, s.MessagesSince(r.PathValue("topic"), since, 200))
}))
// people
mux.HandleFunc("GET /api/v1/users", s.auth(false, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.listUsers()) }))
mux.HandleFunc("POST /api/v1/users", s.auth(true, s.createUserHTTP))
mux.HandleFunc("DELETE /api/v1/users/{id}", s.auth(true, s.deleteUser))
mux.HandleFunc("POST /api/v1/users/{id}/password", s.auth(false, s.passwordHTTP))
mux.HandleFunc("GET /api/v1/apikeys", s.auth(false, s.listKeys))
mux.HandleFunc("POST /api/v1/apikeys", s.auth(false, s.createKeyHTTP))
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", s.auth(false, s.deleteKey))
// configuration documents
s.crud(mux, "channels", "channel", true, func() any { return &Channel{} }, func(v any, id string) error {
ch := v.(*Channel)
ch.ID = id
if ch.Target == redactedTarget { // an edit that left the hidden secret alone keeps the stored one
old, err := getOf[Channel](s.st, "channel", id)
if err != nil {
return err
}
ch.Target = s.st.open(old.Target)
}
if err := validateChannel(ch); err != nil {
return err
}
ch.Target = s.st.seal(ch.Target)
return nil
}, redactChannel)
s.crud(mux, "monitors", "monitor", true, func() any { return &Monitor{} }, func(v any, id string) error {
m := v.(*Monitor)
m.ID = id
return validateMonitor(m)
}, nil)
s.crud(mux, "schedules", "schedule", true, func() any { return &Schedule{} }, func(v any, id string) error {
sc := v.(*Schedule)
sc.ID = id
if strings.TrimSpace(sc.Name) == "" {
return errors.New("give the schedule a name")
}
if sc.RotationHours < 1 {
sc.RotationHours = 24 * 7
}
if sc.Start == 0 {
sc.Start = s.now().Unix()
}
for _, o := range sc.Overrides {
if o.To <= o.From {
return errors.New("an override must end after it starts")
}
}
return nil
}, nil)
s.crud(mux, "policies", "policy", true, func() any { return &Policy{} }, func(v any, id string) error {
p := v.(*Policy)
p.ID = id
if strings.TrimSpace(p.Name) == "" {
return errors.New("give the policy a name")
}
if len(p.Steps) == 0 {
return errors.New("a policy needs at least one step")
}
return nil
}, nil)
s.crud(mux, "websites", "website", true, func() any { return &Website{} }, prepareWebsite, nil)
mux.HandleFunc("GET /api/v1/websites/{id}/stats", s.auth(false, s.websiteStatsHandler))
mux.HandleFunc("GET /api/v1/website-summary", s.auth(false, s.websiteSummary))
mux.HandleFunc("GET /api/v1/system/update", s.auth(true, s.updateStatus))
mux.HandleFunc("POST /api/v1/system/update", s.auth(true, s.updateApply))
mux.HandleFunc("POST /api/v1/channels/{id}/test", s.auth(true, s.testChannel))
mux.HandleFunc("GET /api/v1/oncall", s.auth(false, s.onCallNow))
// monitors
mux.HandleFunc("GET /api/v1/monitors/{id}/checks", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, s.recentChecks(r.PathValue("id"), 100))
}))
mux.HandleFunc("GET /api/v1/monitor-states", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
out := map[string]MonitorState{}
mons, _ := listOf[Monitor](s.st, "monitor")
for _, m := range mons {
out[m.ID] = s.monitorState(m.ID)
}
writeJSON(w, 200, out)
}))
// alerts
mux.HandleFunc("GET /api/v1/alerts", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 500 {
limit = 100
}
writeJSON(w, 200, s.listAlerts(r.URL.Query().Get("status"), limit))
}))
mux.HandleFunc("POST /api/v1/alerts", s.auth(false, s.inboundAlert))
mux.HandleFunc("POST /api/v1/integrations/alertmanager", s.auth(false, s.alertmanager))
mux.HandleFunc("GET /api/v1/alerts/{id}", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
a, err := s.getAlert(r.PathValue("id"))
if err != nil {
fail(w, err)
return
}
a.Timeline = s.st.events("alert", a.ID)
writeJSON(w, 200, a)
}))
mux.HandleFunc("POST /api/v1/alerts/{id}/ack", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.AckAlert(r.PathValue("id"), userFrom(r.Context()).Name))
}))
mux.HandleFunc("POST /api/v1/alerts/{id}/escalate", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.EscalateAlert(r.PathValue("id"), userFrom(r.Context()).Name))
}))
mux.HandleFunc("POST /api/v1/alerts/{id}/resolve", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.ResolveAlert(r.PathValue("id"), userFrom(r.Context()).Name))
}))
mux.HandleFunc("POST /api/v1/alerts/{id}/snooze", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
var in struct {
Minutes int `json:"minutes"`
}
decode(r, &in)
if in.Minutes < 1 || in.Minutes > 24*60 {
in.Minutes = 30
}
fail2(w, s.SnoozeAlert(r.PathValue("id"), userFrom(r.Context()).Name, time.Duration(in.Minutes)*time.Minute))
}))
// incidents
mux.HandleFunc("GET /api/v1/incidents", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
list, err := listOf[Incident](s.st, "incident")
if err != nil {
fail(w, err)
return
}
sortIncidents(list) // ids are random, so order by time
writeJSON(w, 200, list)
}))
mux.HandleFunc("POST /api/v1/incidents", s.auth(false, s.declareHTTP))
mux.HandleFunc("GET /api/v1/incidents/{id}", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
in, err := getOf[Incident](s.st, "incident", r.PathValue("id"))
if err != nil {
fail(w, err)
return
}
in.Timeline = s.st.events("incident", in.ID)
writeJSON(w, 200, in)
}))
mux.HandleFunc("PATCH /api/v1/incidents/{id}", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
var p IncidentPatch
if !decode(r, &p) {
bad(w, "invalid request")
return
}
in, err := s.UpdateIncident(r.PathValue("id"), userFrom(r.Context()).Name, p)
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, in)
}))
mux.HandleFunc("POST /api/v1/incidents/{id}/comments", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
var in struct {
Text string `json:"text"`
}
decode(r, &in)
fail2(w, s.CommentIncident(r.PathValue("id"), userFrom(r.Context()).Name, in.Text))
}))
mux.HandleFunc("POST /api/v1/incidents/{id}/postmortem-template", s.auth(false, func(w http.ResponseWriter, r *http.Request) {
in, err := getOf[Incident](s.st, "incident", r.PathValue("id"))
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, map[string]string{"template": s.postmortemTemplate(in)})
}))
mux.HandleFunc("DELETE /api/v1/incidents/{id}", s.auth(true, func(w http.ResponseWriter, r *http.Request) {
fail2(w, s.st.del("incident", r.PathValue("id")))
}))
return s.statusHost(s.appRoutes(secureHeaders(mux)))
}
var ok = map[string]bool{"ok": true}
func servePage(w http.ResponseWriter, page []byte) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(page)
}
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
img := "'self' data:"
if strings.HasPrefix(r.URL.Path, "/status") {
img += " https:" // the status page can show a logo hosted elsewhere
}
h.Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; img-src "+img)
next.ServeHTTP(w, r)
})
}
// ---------- helpers ----------
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
// bad answers 400. Messages are capitalized here, in one place, so errors read as sentences.
func bad(w http.ResponseWriter, msg string) {
if msg != "" {
r, n := utf8.DecodeRuneInString(msg)
msg = string(unicode.ToUpper(r)) + msg[n:]
}
writeJSON(w, 400, map[string]string{"error": msg})
}
func fail(w http.ResponseWriter, err error) {
if errors.Is(err, errNotFound) {
writeJSON(w, 404, map[string]string{"error": "not found"})
return
}
bad(w, err.Error())
}
// fail2 answers with an error, or with ok when err is nil.
func fail2(w http.ResponseWriter, err error) {
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, ok)
}
func decode(r *http.Request, v any) bool {
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)).Decode(v) == nil
}
// auth wraps a handler: it needs a signed-in user (admin if adminOnly). Requests
// that change things with a cookie must be JSON, which a cross-site form can't send.
func (s *Server) auth(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u := s.authenticate(r)
if u == nil {
w.Header().Set("WWW-Authenticate", `Basic realm="sandbar"`)
writeJSON(w, 401, map[string]string{"error": "sign in or send an API key"})
return
}
if adminOnly && u.Role != "admin" {
writeJSON(w, 403, map[string]string{"error": "administrators only"})
return
}
_, noCookie := r.Cookie(sessionCookie)
if noCookie == nil && r.Method != "GET" && strings.HasPrefix(r.URL.Path, "/api/") &&
!strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
bad(w, "send application/json")
return
}
h(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, u)))
}
}
// crud registers list/create/update/delete for a kind of configuration document.
func (s *Server) crud(mux *http.ServeMux, path, kind string, adminWrite bool, newItem func() any, prepare func(v any, id string) error, redact func(any) any) {
base := "/api/v1/" + path
read := func(v any) any {
if redact != nil {
return redact(v)
}
return v
}
mux.HandleFunc("GET "+base, s.auth(false, func(w http.ResponseWriter, r *http.Request) {
var out []any
s.st.list(kind, func(raw []byte) error {
v := newItem()
if err := json.Unmarshal(raw, v); err != nil {
return err
}
out = append(out, read(v))
return nil
})
if out == nil {
out = []any{}
}
writeJSON(w, 200, out)
}))
save := func(w http.ResponseWriter, r *http.Request, id string) {
v := newItem()
if !decode(r, v) {
bad(w, "invalid request")
return
}
if err := prepare(v, id); err != nil {
bad(w, err.Error())
return
}
if err := s.st.put(kind, id, v); err != nil {
fail(w, err)
return
}
writeJSON(w, 200, read(v))
}
mux.HandleFunc("POST "+base, s.auth(adminWrite, func(w http.ResponseWriter, r *http.Request) { save(w, r, newID()) }))
mux.HandleFunc("PUT "+base+"/{id}", s.auth(adminWrite, func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.st.get(kind, id, newItem()); err != nil {
fail(w, err)
return
}
// A channel's secret target is never sent to the browser; an unchanged form sends the placeholder.
save(w, r, id)
}))
mux.HandleFunc("DELETE "+base+"/{id}", s.auth(adminWrite, func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if kind == "monitor" {
s.deleteMonitorData(id)
}
if kind == "website" {
s.deleteWebsiteData(id)
}
fail2(w, s.st.del(kind, id))
}))
}
func redactChannel(v any) any {
c := *(v.(*Channel))
c.Target = redactedTarget // webhook URLs and addresses are secrets; re-enter to change
return c
}
const redactedTarget = "••••••••"
// ---------- session ----------
func (s *Server) getState(w http.ResponseWriter, r *http.Request) {
out := map[string]any{"needsSetup": s.countUsers() == 0, "user": nil, "url": s.opts.URL}
if u := s.authenticate(r); u != nil {
out["user"] = u
}
writeJSON(w, 200, out)
}
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
var in struct{ Name, Email, Password string }
if !decode(r, &in) {
bad(w, "invalid request")
return
}
if s.countUsers() > 0 {
writeJSON(w, 403, map[string]string{"error": "already set up"})
return
}
if _, err := s.createUser(in.Name, in.Email, in.Password, "admin", ""); err != nil {
bad(w, err.Error())
return
}
u, err := s.login(w, r, in.Email, in.Password)
if err != nil {
bad(w, err.Error())
return
}
writeJSON(w, 200, u)
}
func (s *Server) apiLogin(w http.ResponseWriter, r *http.Request) {
var in struct{ Email, Password string }
if !decode(r, &in) {
bad(w, "invalid request")
return
}
u, err := s.login(w, r, in.Email, in.Password)
if err != nil {
code := 401
if errors.Is(err, errThrottled) {
code = 429
}
writeJSON(w, code, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, u)
}
// ---------- people ----------
func (s *Server) createUserHTTP(w http.ResponseWriter, r *http.Request) {
var in struct{ Name, Email, Password, Role, Phone string }
if !decode(r, &in) {
bad(w, "invalid request")
return
}
if in.Role == "" {
in.Role = "member"
}
u, err := s.createUser(in.Name, in.Email, in.Password, in.Role, in.Phone)
if err != nil {
bad(w, err.Error())
return
}
writeJSON(w, 200, u)
}
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == userFrom(r.Context()).ID {
bad(w, "you can't delete yourself")
return
}
res, err := s.st.db.Exec(`DELETE FROM users WHERE id=?`, id)
if err != nil {
fail(w, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(w, errNotFound)
return
}
writeJSON(w, 200, ok)
}
func (s *Server) passwordHTTP(w http.ResponseWriter, r *http.Request) {
me := userFrom(r.Context())
id := r.PathValue("id")
if id != me.ID && me.Role != "admin" {
writeJSON(w, 403, map[string]string{"error": "you can only change your own password"})
return
}
var in struct{ Password string }
if !decode(r, &in) {
bad(w, "invalid request")
return
}
fail2(w, s.setPassword(id, in.Password))
}
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request) {
out := []map[string]any{}
rows, err := s.st.db.Query(`SELECT id,name,created FROM apikeys WHERE user_id=? ORDER BY created`, userFrom(r.Context()).ID)
if err == nil {
defer rows.Close()
for rows.Next() {
var id, name string
var c int64
if rows.Scan(&id, &name, &c) == nil {
out = append(out, map[string]any{"id": id, "name": name, "created": c})
}
}
}
writeJSON(w, 200, out)
}
func (s *Server) createKeyHTTP(w http.ResponseWriter, r *http.Request) {
var in struct{ Name string }
decode(r, &in)
id, tok, err := s.createAPIKey(userFrom(r.Context()).ID, in.Name)
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, map[string]string{"id": id, "token": tok}) // shown once
}
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request) {
s.st.db.Exec(`DELETE FROM apikeys WHERE id=? AND user_id=?`, r.PathValue("id"), userFrom(r.Context()).ID)
writeJSON(w, 200, ok)
}
// ---------- topics ----------
func (s *Server) publishHTTP(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
if err != nil {
bad(w, "message too large (limit 64 KB)")
return
}
h := func(names ...string) string {
for _, n := range names {
if v := r.Header.Get(n); v != "" {
return v
}
if v := r.URL.Query().Get(strings.ToLower(n)); v != "" {
return v
}
}
return ""
}
m, err := s.Publish(topic, h("Title", "X-Title", "t"), string(body), parsePriority(h("Priority", "X-Priority", "p")), parseTags(h("Tags", "X-Tags", "ta")))
if err != nil {
bad(w, err.Error())
return
}
m.Event = "message"
writeJSON(w, 200, m)
}
// subscribeHTTP streams a topic as server-sent events (/sse) or JSON lines (/json).
// ?since= replays stored messages first: "all", unix seconds, a duration such as 10m, or the id
// of the last message the client saw. ?poll=1 sends only that replay and then ends, which is
// how the ntfy apps catch up.
func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic")
if !validTopic(topic) {
bad(w, "invalid topic")
return
}
fl, canFlush := w.(http.Flusher)
if !canFlush {
http.Error(w, "streaming not supported", 500)
return
}
sse := strings.HasSuffix(r.URL.Path, "/sse")
if sse {
w.Header().Set("Content-Type", "text/event-stream")
} else {
w.Header().Set("Content-Type", "application/x-ndjson")
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
bw := bufio.NewWriter(w)
event := func(kind string) {
b, _ := json.Marshal(map[string]any{"id": newID(), "time": s.now().Unix(), "event": kind, "topic": topic})
fmt.Fprintf(bw, "%s\n", b)
bw.Flush()
fl.Flush()
}
write := func(m Message) bool {
m.Event = "message"
b, _ := json.Marshal(m)
var err error
if sse {
_, err = fmt.Fprintf(bw, "id: %s\ndata: %s\n\n", m.ID, b)
} else {
_, err = fmt.Fprintf(bw, "%s\n", b)
}
bw.Flush()
fl.Flush()
return err == nil
}
// Subscribe before replaying so nothing published in between is missed; duplicates are skipped by id.
ch, unsub := s.hub.subscribe(topic)
defer unsub()
seen := map[string]bool{}
if q := r.URL.Query().Get("since"); q != "" {
for _, m := range s.replaySince(topic, q) {
seen[m.ID] = true
if !write(m) {
return
}
}
}
if r.URL.Query().Get("poll") == "1" {
return
}
if sse {
fmt.Fprint(bw, ": connected\n\n")
bw.Flush()
fl.Flush()
} else {
event("open")
}
keepalive := time.NewTicker(30 * time.Second)
defer keepalive.Stop()
for {
select {
case <-r.Context().Done():
return
case <-s.stop:
return
case m := <-ch:
if seen[m.ID] {
continue
}
if !write(m) {
return
}
case <-keepalive.C:
if sse {
fmt.Fprint(bw, ": keepalive\n\n")
bw.Flush()
fl.Flush()
} else {
event("keepalive")
}
}
}
}
// replaySince returns the stored messages a subscriber should see first for a ?since= value.
func (s *Server) replaySince(topic, q string) []Message {
if q == "all" {
return s.MessagesSince(topic, 0, 500)
}
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
return s.MessagesSince(topic, n, 500)
}
if d, err := time.ParseDuration(q); err == nil {
return s.MessagesSince(topic, s.now().Add(-d).Unix(), 500)
}
// Otherwise it is the id of the last message the client has: send what came after it.
var at int64
if s.st.db.QueryRow(`SELECT time FROM messages WHERE topic=? AND id=?`, topic, q).Scan(&at) != nil {
return nil
}
all := s.MessagesSince(topic, at, 500)
for i, m := range all {
if m.ID == q {
return all[i+1:]
}
}
return all
}
// ---------- alerts in ----------
func (s *Server) inboundAlert(w http.ResponseWriter, r *http.Request) {
var in struct {
Key string `json:"key"`
Summary string `json:"summary"`
Detail string `json:"detail"`
Severity string `json:"severity"`
Policy string `json:"policy"`
Status string `json:"status"` // firing (default) or resolved
Source string `json:"source"`
}
if !decode(r, &in) {
bad(w, "invalid request")
return
}
if in.Key == "" {
in.Key = in.Summary
}
if in.Key == "" {
bad(w, "send a key or a summary")
return
}
if in.Source == "" {
in.Source = "api"
}
in.Key = "ext:" + in.Key
if in.Status == "resolved" {
s.ResolveAlertKey(in.Key, in.Source)
writeJSON(w, 200, ok)
return
}
if in.Summary == "" {
in.Summary = in.Key
}
if in.Policy != "" {
if _, err := getOf[Policy](s.st, "policy", in.Policy); err != nil {
bad(w, "unknown escalation policy")
return
}
}
a, isNew, err := s.RaiseAlert(in.Key, in.Source, in.Summary, in.Detail, in.Severity, in.Policy)
if err != nil {
fail(w, err)
return
}
writeJSON(w, 200, map[string]any{"id": a.ID, "new": isNew})
}
// alertmanager accepts Prometheus Alertmanager's webhook (also Grafana's, which uses the same shape).
// ?policy=<id> sets the escalation policy.
func (s *Server) alertmanager(w http.ResponseWriter, r *http.Request) {
var in struct {
Alerts []struct {
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
Fingerprint string `json:"fingerprint"`
} `json:"alerts"`
}
if !decode(r, &in) {
bad(w, "invalid request")
return
}
policy := r.URL.Query().Get("policy")
if policy != "" {
if _, err := getOf[Policy](s.st, "policy", policy); err != nil {
bad(w, "unknown escalation policy")
return
}
}
n := 0
for _, a := range in.Alerts {
key := a.Fingerprint
if key == "" {
key = a.Labels["alertname"] + "/" + a.Labels["instance"]
}
key = "am:" + key
if a.Status == "resolved" {
s.ResolveAlertKey(key, "alertmanager")
continue
}
sev := map[string]string{"critical": "critical", "warning": "warning", "info": "info"}[a.Labels["severity"]]
summary := firstNonEmpty(a.Annotations["summary"], a.Labels["alertname"], key)
if _, _, err := s.RaiseAlert(key, "alertmanager", summary, a.Annotations["description"], sev, policy); err == nil {
n++
}
}
writeJSON(w, 200, map[string]int{"processed": n})
}
// ---------- misc ----------
func (s *Server) declareHTTP(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string `json:"title"`
Severity string `json:"severity"`
Summary string `json:"summary"`
Lead string `json:"lead"`
AlertIDs []string `json:"alertIds"`
// Public puts the incident on the status page, naming the affected components, as it is declared.
Public bool `json:"public"`
Components []string `json:"components"`
}
if !decode(r, &in) {
bad(w, "invalid request")
return
}
me := userFrom(r.Context())
if in.Lead == "" {
in.Lead = me.ID
}
inc, err := s.DeclareIncident(in.Title, in.Severity, in.Summary, in.Lead, me.Name, in.AlertIDs)
if err != nil {
bad(w, err.Error())
return
}
if in.Public {
pub, comps := true, in.Components
if inc, err = s.UpdateIncident(inc.ID, me.Name, IncidentPatch{Public: &pub, Components: &comps}); err != nil {
fail(w, err)
return
}
}
writeJSON(w, 200, inc)
}
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request) {
ch, err := getOf[Channel](s.st, "channel", r.PathValue("id"))
if err != nil {
fail(w, err)
return
}
if err := s.sendChannel(*ch, Note{Status: "test", Kind: "alert", Title: "test", Time: s.now().Unix()}); err != nil {
bad(w, err.Error())
return
}
writeJSON(w, 200, ok)
}
func (s *Server) onCallNow(w http.ResponseWriter, r *http.Request) {
type row struct {
Schedule string `json:"schedule"`
ID string `json:"id"`
UserID string `json:"userId"`
User string `json:"user"`
Next string `json:"next"` // who is on call after the current handover
}
out := []row{}
scs, _ := listOf[Schedule](s.st, "schedule")
now := s.now()
for _, sc := range scs {
sc := sc
uid := sc.OnCall(now)
nx := sc.OnCall(now.Add(time.Duration(sc.RotationHours) * time.Hour))
out = append(out, row{sc.Name, sc.ID, uid, s.userName(uid), s.userName(nx)})
}
writeJSON(w, 200, out)
}
func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request) {
if !s.Heartbeat(r.PathValue("token")) {
http.NotFound(w, r)
return
}
io.WriteString(w, "ok")
}
func sortIncidents(list []Incident) {
for i := 1; i < len(list); i++ { // newest first; lists are short
for j := i; j > 0 && list[j].Created > list[j-1].Created; j-- {
list[j], list[j-1] = list[j-1], list[j]
}
}
}
func (s *Server) metrics(w http.ResponseWriter, r *http.Request) {
if s.opts.MetricsToken == "" {
http.NotFound(w, r)
return
}
if h := r.Header.Get("Authorization"); h != "Bearer "+s.opts.MetricsToken {
w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`)
http.Error(w, "unauthorized", 401)
return
}
var b strings.Builder
b.WriteString("# TYPE sandbar_monitor_up gauge\n")
mons, _ := listOf[Monitor](s.st, "monitor")
for _, m := range mons {
v := 0
if s.monitorState(m.ID).Status == "up" {
v = 1
}
fmt.Fprintf(&b, "sandbar_monitor_up{monitor=%q} %d\n", m.Name, v)
}
var firing, acked, open int
s.st.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE status='firing'`).Scan(&firing)
s.st.db.QueryRow(`SELECT COUNT(*) FROM alerts WHERE status='acked'`).Scan(&acked)
incs, _ := listOf[Incident](s.st, "incident")
for _, in := range incs {
if in.Status != "resolved" {
open++
}
}
fmt.Fprintf(&b, "# TYPE sandbar_alerts gauge\nsandbar_alerts{status=\"firing\"} %d\nsandbar_alerts{status=\"acked\"} %d\n", firing, acked)
fmt.Fprintf(&b, "# TYPE sandbar_incidents_open gauge\nsandbar_incidents_open %d\n", open)
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
io.WriteString(w, b.String())
}