Components can be put in groups and given public names. Public incidents carry posts written for customers (the internal timeline stays internal), the components they affect, and a page for each incident plus paged history. Scheduled maintenance has its own window, shows affected components as under maintenance, and is announced when scheduled, started, ended or canceled. Branding (title, logo, accent checked for contrast, contact link, footer) and a custom domain that serves only the status page. Visitors follow by RSS, by confirmed email, or by webhook that must answer a test request. Admin UI for all of it, tests, README and design notes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
866 lines
25 KiB
Go
866 lines
25 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) })
|
|
|
|
// 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}/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(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())
|
|
}
|