Turn the status page into a full status page generator
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>
This commit is contained in:
1 parent
b4fd31a182
commit
cf4d5b3ce2
12 files changed
+2403
-147
No files matched your search
@@ -0,0 +1,414 @@
|
||||
package sandbar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Following the status page: email (confirmed by a link), webhook (confirmed by answering a test
|
||||
// request) and RSS. Every update to a public incident, and every maintenance window, goes to all
|
||||
// confirmed subscribers.
|
||||
|
||||
// statusNote is one update, as sent to subscribers.
|
||||
type statusNote struct {
|
||||
Event string `json:"event"` // incident.created, incident.updated, incident.resolved, maintenance.scheduled, .rescheduled, .started, .completed, .canceled
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
URL string `json:"url"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
func (n statusNote) subject(page string) string {
|
||||
label := map[string]string{
|
||||
"incident.created": "New incident", "incident.updated": "Incident update", "incident.resolved": "Resolved",
|
||||
"maintenance.scheduled": "Scheduled maintenance", "maintenance.rescheduled": "Maintenance rescheduled",
|
||||
"maintenance.started": "Maintenance started", "maintenance.completed": "Maintenance complete", "maintenance.canceled": "Maintenance canceled",
|
||||
}[n.Event]
|
||||
return "[" + page + "] " + label + ": " + n.Title
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // email or webhook
|
||||
Target string `json:"target"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
func (s *Server) mailEnabled() bool { return s.opts.Mailer != nil || s.opts.SMTP.Host != "" }
|
||||
|
||||
// ---------- links that act for a subscriber ----------
|
||||
|
||||
// statusSecret signs confirm and unsubscribe links, so none needs a stored token.
|
||||
func (s *Server) statusSecret() []byte {
|
||||
s.statusMu.Lock()
|
||||
defer s.statusMu.Unlock()
|
||||
if s.statusKey == nil {
|
||||
var v struct{ Value string }
|
||||
if s.st.get("secret", "status", &v) != nil || v.Value == "" {
|
||||
v.Value = newToken()
|
||||
s.st.put("secret", "status", v)
|
||||
}
|
||||
s.statusKey = []byte(v.Value)
|
||||
}
|
||||
return s.statusKey
|
||||
}
|
||||
|
||||
func (s *Server) subToken(purpose, id string) string {
|
||||
m := hmac.New(sha256.New, s.statusSecret())
|
||||
m.Write([]byte(purpose + "." + id))
|
||||
return id + "." + hex.EncodeToString(m.Sum(nil))[:32]
|
||||
}
|
||||
|
||||
func (s *Server) parseSubToken(purpose, tok string) (string, bool) {
|
||||
id, _, ok := strings.Cut(tok, ".")
|
||||
return id, ok && hmac.Equal([]byte(tok), []byte(s.subToken(purpose, id)))
|
||||
}
|
||||
|
||||
// ---------- storage ----------
|
||||
|
||||
func subKey(kind, target string) string { return hashToken(kind + ":" + strings.ToLower(target)) }
|
||||
|
||||
// addSubscriber stores a subscriber, or returns the one already stored for that address.
|
||||
func (s *Server) addSubscriber(kind, target string, confirmed bool) (sub subscriber, existed bool, err error) {
|
||||
var n int
|
||||
s.st.db.QueryRow(`SELECT COUNT(*) FROM subscribers`).Scan(&n)
|
||||
key := subKey(kind, target)
|
||||
if got, ok := s.subscriberBy(`key=?`, key); ok {
|
||||
return got, true, nil
|
||||
}
|
||||
if n >= 20000 {
|
||||
return sub, false, errors.New("this status page cannot take more subscribers")
|
||||
}
|
||||
sub = subscriber{ID: newID(), Kind: kind, Target: target, Confirmed: confirmed, Created: s.now().Unix()}
|
||||
_, err = s.st.db.Exec(`INSERT INTO subscribers(id,kind,target,key,confirmed,created) VALUES(?,?,?,?,?,?)`,
|
||||
sub.ID, kind, s.st.seal(target), key, boolInt(confirmed), sub.Created)
|
||||
return sub, false, err
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Server) subscriberBy(where string, args ...any) (subscriber, bool) {
|
||||
var sub subscriber
|
||||
var sealed string
|
||||
var conf int
|
||||
err := s.st.db.QueryRow(`SELECT id,kind,target,confirmed,created FROM subscribers WHERE `+where, args...).Scan(&sub.ID, &sub.Kind, &sealed, &conf, &sub.Created)
|
||||
if errors.Is(err, sql.ErrNoRows) || err != nil {
|
||||
return sub, false
|
||||
}
|
||||
sub.Target, sub.Confirmed = s.st.open(sealed), conf == 1
|
||||
return sub, true
|
||||
}
|
||||
|
||||
func (s *Server) confirmedSubscribers() []subscriber {
|
||||
rows, err := s.st.db.Query(`SELECT id,kind,target,created FROM subscribers WHERE confirmed=1`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []subscriber
|
||||
for rows.Next() {
|
||||
var sub subscriber
|
||||
var sealed string
|
||||
if rows.Scan(&sub.ID, &sub.Kind, &sealed, &sub.Created) == nil {
|
||||
sub.Target, sub.Confirmed = s.st.open(sealed), true
|
||||
out = append(out, sub)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listSubscribers is for administrators. A webhook address often carries a secret, so only its host is shown.
|
||||
func (s *Server) listSubscribers(w http.ResponseWriter, r *http.Request) {
|
||||
out := []subscriber{}
|
||||
rows, err := s.st.db.Query(`SELECT id,kind,target,confirmed,created FROM subscribers ORDER BY created DESC LIMIT 1000`)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var sub subscriber
|
||||
var sealed string
|
||||
var conf int
|
||||
if rows.Scan(&sub.ID, &sub.Kind, &sealed, &conf, &sub.Created) != nil {
|
||||
continue
|
||||
}
|
||||
sub.Target, sub.Confirmed = s.st.open(sealed), conf == 1
|
||||
if sub.Kind == "webhook" {
|
||||
if u, err := url.Parse(sub.Target); err == nil {
|
||||
sub.Target = u.Host
|
||||
}
|
||||
}
|
||||
out = append(out, sub)
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// ---------- sending ----------
|
||||
|
||||
func (s *Server) notifySubscribers(n statusNote) {
|
||||
subs := s.confirmedSubscribers()
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
title := s.statusSettings().Title
|
||||
s.notifyWG.Add(1)
|
||||
go func() {
|
||||
defer s.notifyWG.Done()
|
||||
for _, sub := range subs {
|
||||
if err := s.deliver(sub, n, title); err != nil {
|
||||
log.Printf("status page subscriber %s: %v", sub.ID, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) deliver(sub subscriber, n statusNote, pageTitle string) error {
|
||||
unsub := s.absStatusURL(nil, "/unsubscribe?token="+url.QueryEscape(s.subToken("unsubscribe", sub.ID)))
|
||||
switch sub.Kind {
|
||||
case "email":
|
||||
body := n.Message + "\n\n" + n.URL + "\n\nYou get this because you subscribed to updates from " + pageTitle + ". Unsubscribe: " + unsub
|
||||
return s.Mail(sub.Target, n.subject(pageTitle), body)
|
||||
case "webhook":
|
||||
return s.postWebhook(sub.Target, map[string]any{"page": pageTitle, "event": n.Event, "title": n.Title, "status": n.Status, "message": n.Message,
|
||||
"url": n.URL, "time": n.Time, "unsubscribe": unsub})
|
||||
}
|
||||
return errors.New("unknown subscriber kind")
|
||||
}
|
||||
|
||||
func (s *Server) postWebhook(target string, payload any) error {
|
||||
b, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest("POST", target, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Sandbar status page")
|
||||
resp, err := s.httpClient(false).Do(req) // visitors choose these addresses, so private networks stay out of reach
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("the webhook answered %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- public endpoints ----------
|
||||
|
||||
// subscribeAllowed lets each address make a handful of requests an hour.
|
||||
func (s *Server) subscribeAllowed(ip string) bool {
|
||||
s.subMu.Lock()
|
||||
defer s.subMu.Unlock()
|
||||
now := s.now()
|
||||
keep := s.subTry[ip][:0]
|
||||
for _, t := range s.subTry[ip] {
|
||||
if now.Sub(t) < time.Hour {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
if len(keep) >= 6 {
|
||||
s.subTry[ip] = keep
|
||||
return false
|
||||
}
|
||||
if len(s.subTry) > 5000 { // forget addresses that have gone quiet
|
||||
for k, v := range s.subTry {
|
||||
if len(v) == 0 || now.Sub(v[len(v)-1]) >= time.Hour {
|
||||
delete(s.subTry, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.subTry[ip] = append(keep, now)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) followStatusHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
Webhook string `json:"webhook"`
|
||||
}
|
||||
if !decode(r, &in) {
|
||||
bad(w, "invalid request")
|
||||
return
|
||||
}
|
||||
if !s.subscribeAllowed(s.realIP(r)) {
|
||||
writeJSON(w, 429, map[string]string{"error": "Too many attempts. Try again in an hour."})
|
||||
return
|
||||
}
|
||||
title := s.statusSettings().Title
|
||||
switch {
|
||||
case strings.TrimSpace(in.Email) != "":
|
||||
if !s.mailEnabled() {
|
||||
bad(w, "email updates are not set up on this page")
|
||||
return
|
||||
}
|
||||
addr := strings.TrimSpace(in.Email)
|
||||
if a, err := mail.ParseAddress(addr); err != nil || a.Address != addr || len(addr) > 254 || strings.ContainsAny(addr, "\r\n<>") {
|
||||
bad(w, "enter a valid email address")
|
||||
return
|
||||
}
|
||||
sub, existed, err := s.addSubscriber("email", addr, false)
|
||||
if err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
// The answer is the same whether or not the address was known, so the form cannot be used to probe it.
|
||||
if !(existed && sub.Confirmed) {
|
||||
link := s.absStatusURL(r, "/confirm?token="+url.QueryEscape(s.subToken("confirm", sub.ID)))
|
||||
body := "Confirm that you want email updates from " + title + " by opening this link:\n\n" + link + "\n\nIf you did not ask for this, ignore this email and nothing will be sent."
|
||||
if err := s.Mail(addr, "["+title+"] Confirm your subscription", body); err != nil {
|
||||
log.Printf("status page confirmation to %s: %v", addr, err)
|
||||
writeJSON(w, 502, map[string]string{"error": "The confirmation email could not be sent. Try again later."})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"message": "Check your email and open the link to finish subscribing."})
|
||||
case strings.TrimSpace(in.Webhook) != "":
|
||||
target := strings.TrimSpace(in.Webhook)
|
||||
if u, err := url.Parse(target); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || len(target) > 500 {
|
||||
bad(w, "the webhook must be an http:// or https:// address")
|
||||
return
|
||||
}
|
||||
sub, existed, err := s.addSubscriber("webhook", target, false)
|
||||
if err != nil {
|
||||
bad(w, err.Error())
|
||||
return
|
||||
}
|
||||
if existed && sub.Confirmed {
|
||||
writeJSON(w, 200, map[string]string{"message": "That webhook is subscribed."})
|
||||
return
|
||||
}
|
||||
// Answering a test request shows the address belongs to something that wants these updates.
|
||||
unsub := s.absStatusURL(r, "/unsubscribe?token="+url.QueryEscape(s.subToken("unsubscribe", sub.ID)))
|
||||
if err := s.postWebhook(target, map[string]any{"page": title, "event": "subscription.created", "message": "This address is now subscribed to " + title + ".", "time": s.now().Unix(), "unsubscribe": unsub}); err != nil {
|
||||
s.st.db.Exec(`DELETE FROM subscribers WHERE id=?`, sub.ID)
|
||||
bad(w, "that address did not accept a test request, so it was not subscribed; check the URL and try again")
|
||||
return
|
||||
}
|
||||
s.st.db.Exec(`UPDATE subscribers SET confirmed=1 WHERE id=?`, sub.ID)
|
||||
writeJSON(w, 200, map[string]string{"message": "Subscribed. A test request was sent to the webhook."})
|
||||
default:
|
||||
bad(w, "enter an email address or a webhook address")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) confirmSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
notice := "invalid"
|
||||
if id, ok := s.parseSubToken("confirm", r.URL.Query().Get("token")); ok {
|
||||
if res, err := s.st.db.Exec(`UPDATE subscribers SET confirmed=1 WHERE id=?`, id); err == nil {
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
notice = "confirmed"
|
||||
}
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, pagePrefix(r)+"/?notice="+notice, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// unsubscribeRedirect opens the page with a button instead of acting on a GET, since mail
|
||||
// scanners follow every link in a message.
|
||||
func (s *Server) unsubscribeRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, pagePrefix(r)+"/?unsubscribe="+url.QueryEscape(r.URL.Query().Get("token")), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) unsubscribeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
decode(r, &in)
|
||||
id, ok := s.parseSubToken("unsubscribe", in.Token)
|
||||
if !ok {
|
||||
bad(w, "that unsubscribe link is not valid")
|
||||
return
|
||||
}
|
||||
s.st.db.Exec(`DELETE FROM subscribers WHERE id=?`, id)
|
||||
writeJSON(w, 200, map[string]string{"message": "You are unsubscribed."})
|
||||
}
|
||||
|
||||
// ---------- RSS ----------
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Desc string `xml:"description"`
|
||||
t int64
|
||||
}
|
||||
|
||||
func (s *Server) statusFeed(w http.ResponseWriter, r *http.Request) {
|
||||
now := s.now()
|
||||
cfg := s.statusSettings()
|
||||
_, _, names := s.publicComponents()
|
||||
var items []rssItem
|
||||
add := func(id string, t int64, title, link, text string) {
|
||||
items = append(items, rssItem{Title: title, Link: link, GUID: id, PubDate: time.Unix(t, 0).UTC().Format(time.RFC1123Z), Desc: text, t: t})
|
||||
}
|
||||
incs, _ := listOf[Incident](s.st, "incident")
|
||||
for _, in := range incs {
|
||||
if !in.Public {
|
||||
continue
|
||||
}
|
||||
pi := s.publicIncident(in, names)
|
||||
for _, u := range pi.Updates {
|
||||
add(fmt.Sprintf("%s-%d", in.ID, u.Time), u.Time, statusLabel(u.Status)+": "+in.Title, s.absStatusURL(r, "/incidents/"+in.ID), u.Text)
|
||||
}
|
||||
}
|
||||
for _, m := range s.loadMaintenance() {
|
||||
if m.Canceled {
|
||||
continue
|
||||
}
|
||||
add("maintenance-"+m.ID, m.Created, "Scheduled maintenance: "+m.Title, s.absStatusURL(r, ""), strings.TrimSpace(fmtWindow(m.Start, m.End)+". "+m.Message))
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool { return items[i].t > items[j].t })
|
||||
if len(items) > 50 {
|
||||
items = items[:50]
|
||||
}
|
||||
type channel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
LastBuildDate string `xml:"lastBuildDate"`
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
feed := struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Channel channel `xml:"channel"`
|
||||
}{Version: "2.0", Channel: channel{Title: cfg.Title, Link: s.absStatusURL(r, ""), Description: firstNonEmpty(cfg.Description, "Incidents and maintenance for "+cfg.Title),
|
||||
LastBuildDate: now.UTC().Format(time.RFC1123Z), Items: items}}
|
||||
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "max-age=60")
|
||||
w.Write([]byte(xml.Header))
|
||||
enc := xml.NewEncoder(w)
|
||||
enc.Indent("", " ")
|
||||
enc.Encode(feed)
|
||||
}
|
||||
|
||||
func statusLabel(status string) string {
|
||||
if l := map[string]string{"investigating": "Investigating", "identified": "Identified", "monitoring": "Monitoring", "resolved": "Resolved"}[status]; l != "" {
|
||||
return l
|
||||
}
|
||||
return "Update"
|
||||
}
|
||||
Reference in new issue
Block a user